chore: move frontend files
109
frontend/src/App.vue
Normal file
@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<Ready>
|
||||
<template v-if="authUser">
|
||||
<TheNavigation />
|
||||
<ContentAuth />
|
||||
</template>
|
||||
<ContentLinkShare v-else-if="authLinkShare" />
|
||||
<NoAuthWrapper v-else>
|
||||
<router-view />
|
||||
</NoAuthWrapper>
|
||||
|
||||
<KeyboardShortcuts v-if="keyboardShortcutsActive" />
|
||||
|
||||
<Teleport to="body">
|
||||
<AddToHomeScreen />
|
||||
<UpdateNotification />
|
||||
<Notification />
|
||||
<DemoMode />
|
||||
</Teleport>
|
||||
</Ready>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, watch} from 'vue'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import isTouchDevice from 'is-touch-device'
|
||||
|
||||
import Notification from '@/components/misc/notification.vue'
|
||||
import UpdateNotification from '@/components/home/UpdateNotification.vue'
|
||||
import KeyboardShortcuts from '@/components/misc/keyboard-shortcuts/index.vue'
|
||||
|
||||
import TheNavigation from '@/components/home/TheNavigation.vue'
|
||||
import ContentAuth from '@/components/home/contentAuth.vue'
|
||||
import ContentLinkShare from '@/components/home/contentLinkShare.vue'
|
||||
import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
|
||||
import Ready from '@/components/misc/ready.vue'
|
||||
|
||||
import {setLanguage} from '@/i18n'
|
||||
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
|
||||
import {useColorScheme} from '@/composables/useColorScheme'
|
||||
import {useBodyClass} from '@/composables/useBodyClass'
|
||||
import AddToHomeScreen from '@/components/home/AddToHomeScreen.vue'
|
||||
import DemoMode from '@/components/home/DemoMode.vue'
|
||||
|
||||
const importAccountDeleteService = () => import('@/services/accountDelete')
|
||||
const importMessage = () => import('@/message')
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
useBodyClass('is-touch', isTouchDevice())
|
||||
const keyboardShortcutsActive = computed(() => baseStore.keyboardShortcutsActive)
|
||||
|
||||
const authUser = computed(() => authStore.authUser)
|
||||
const authLinkShare = computed(() => authStore.authLinkShare)
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
// setup account deletion verification
|
||||
const accountDeletionConfirm = computed(() => route.query?.accountDeletionConfirm as (string | undefined))
|
||||
watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
|
||||
if (accountDeletionConfirm === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const messageP = importMessage()
|
||||
const AccountDeleteService = (await importAccountDeleteService()).default
|
||||
const accountDeletionService = new AccountDeleteService()
|
||||
await accountDeletionService.confirm(accountDeletionConfirm)
|
||||
const {success} = await messageP
|
||||
success({message: t('user.deletion.confirmSuccess')})
|
||||
authStore.refreshUserInfo()
|
||||
}, { immediate: true })
|
||||
|
||||
// setup password reset redirect
|
||||
const userPasswordReset = computed(() => route.query?.userPasswordReset as (string | undefined))
|
||||
watch(userPasswordReset, (userPasswordReset) => {
|
||||
if (userPasswordReset === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem('passwordResetToken', userPasswordReset)
|
||||
router.push({name: 'user.password-reset.reset'})
|
||||
}, { immediate: true })
|
||||
|
||||
// setup email verification redirect
|
||||
const userEmailConfirm = computed(() => route.query?.userEmailConfirm as (string | undefined))
|
||||
watch(userEmailConfirm, (userEmailConfirm) => {
|
||||
if (userEmailConfirm === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem('emailConfirmToken', userEmailConfirm)
|
||||
router.push({name: 'user.login'})
|
||||
}, { immediate: true })
|
||||
|
||||
setLanguage(authStore.settings.language)
|
||||
useColorScheme()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '@/styles/global.scss';
|
||||
</style>
|
BIN
frontend/src/assets/audio/pop.mp3
Normal file
4
frontend/src/assets/checkbox.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18px" height="18px" viewBox="0 0 18 18" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5">
|
||||
<path d="M1,9 L1,3.5 C1,2 2,1 3.5,1 L14.5,1 C16,1 17,2 17,3.5 L17,14.5 C17,16 16,17 14.5,17 L3.5,17 C2,17 1,16 1,14.5 L1,9 Z" stroke-dasharray="60"></path>
|
||||
<polyline points="1 9 7 14 15 4" stroke-dasharray="22" stroke-dashoffset="66"></polyline>
|
||||
</svg>
|
After Width: | Height: | Size: 420 B |
BIN
frontend/src/assets/fonts/OpenSans-BoldItalic_3ff98862.woff2
Normal file
BIN
frontend/src/assets/fonts/OpenSans-Bold_eb52363b.woff2
Normal file
BIN
frontend/src/assets/fonts/OpenSans-Italic[wght]_c9a8fe68.woff2
Normal file
BIN
frontend/src/assets/fonts/OpenSans-RegularItalic_48244a7a.woff2
Normal file
BIN
frontend/src/assets/fonts/OpenSans-Regular_d0acb717.woff2
Normal file
BIN
frontend/src/assets/fonts/OpenSans[wght]_54a65da5.woff2
Normal file
BIN
frontend/src/assets/fonts/Quicksand-Bold_20b26f76.woff2
Normal file
BIN
frontend/src/assets/fonts/Quicksand-Regular_3e913e7e.woff2
Normal file
BIN
frontend/src/assets/fonts/Quicksand-SemiBold_be48a442.woff2
Normal file
BIN
frontend/src/assets/fonts/Quicksand[wght]_87bdcc7f.woff2
Normal file
1
frontend/src/assets/llama-cool.svg
Normal file
After Width: | Height: | Size: 8.3 KiB |
BIN
frontend/src/assets/llama-nightscape.jpg
Normal file
After Width: | Height: | Size: 49 KiB |
1
frontend/src/assets/llama.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="145.4" height="204.5" xml:space="preserve"><defs><clipPath id="a" clipPathUnits="userSpaceOnUse"><path d="M5210 5148.5c-111.4 0-201.8 62.7-201.8 140s90.4 140 201.8 140c111.5 0 201.9-62.7 201.9-140s-90.4-140-201.9-140"/></clipPath></defs><path fill="#fff" d="M145 129.8c-1.5 11.7-6.3 15-6.3 15 8.2 4.3 6.9 16.5 4.5 21-1.5 3-3.6 3-3.6 3 7.4 11-1.4 18.7-1.4 18.7a39 39 0 0 1 1.7 17h-109c-.7-1.7-1.4-4.8-.5-10.3 1.8-11.5 5.5-16.1 6.3-17 0 0-7-10.2-6.3-19.4 1-14.6 4.9-16.7 4.9-16.7-7.4-10-2.4-20.4.4-23.8 0-1.3-3.9-14.8-1.1-27 1-4 3-15.3 3.5-15.9-.5-1.4-3-6.4 1-16.3 1.8-4.2 4.2-7.6 5.9-9.7l1.2-2.8C39.2 23 43-.3 46.9 0c7.2.6 17.2 27.3 18 29.1.2.7 7.8 1.3 16.2 1.9 7.8-.6 15.8-1 20-.4 5.2.9 9.6 2.4 13.3 4.1 5 .7 6.9.9 7.2.3 1.6-3.3 6.9-26.7 16.3-32.5 5.1-3 11.5 36.4-1 48.7 0 0-1 3.7-1.5 6.7 3.6 8 .3 18.7.3 18.7 15 6.2 1.7 28.3 1.7 28.3 3.3 3.6 9.7 8 7.5 25"/><path fill="#fef2e2" d="M136.5 11.6c3.1-5.3 9.9 20-3.2 32.3-3.2 3-3.3-2.1-1-17 .7-5.2 2-11.4 4.2-15.3M50 9c-3.4-5.1-8.8 20.5 5 32.1C58.2 44 58 38.8 55 24c-1-5.1-2.7-11.3-5-15M53.3 60.4c8.4-8.7 33.6-6.6 33.6-6.6s25-3 33.7 5.5c0 0 27.6 61.4-32.7 62.8-60.2.5-34.6-61.7-34.6-61.7"/><g clip-path="url(#a)" transform="matrix(.04413 0 0 .04413 -169.3 -148)"><path fill="#fee8de" d="M5008.2 5148.5h403.7v280h-403.7Z"/></g><path fill="#fee8de" d="M112.6 80.2c-4.9 0-8.9 2.8-8.9 6.2 0 3.4 4 6.2 9 6.2 4.9 0 8.9-2.8 8.9-6.2 0-3.4-4-6.2-9-6.2"/><path fill="#231f20" d="M68.3 74.4a3 3 0 1 0 6-.1c0-1.7-1.4-4.2-3-4.2-1.7 0-3 2.6-3 4.3M100.4 75a3 3 0 1 0 5.9 0c0-1.7-1.4-4.2-3-4.2-1.7 0-3 2.6-3 4.3"/><path fill="#eedbcc" d="M61.5 100.2c-.1-7.7 11.7-16.7 24.2-16.9 12.6-.2 24 8.2 24 15.9.1 4-3.5 6.1-7.9 9.1-4.3 3-9 9.8-15.9 10-7 0-11.7-6.7-16.2-9.6-4.4-2.7-8.1-4.5-8.2-8.5"/><path fill="#fff" d="M48.8 44.7c.3-2.6 2.3-4.8 5-4.7-1.4-2.2-2.3-7 2.2-9.3 2.8-1.3 6 1.1 6 1.1s.6-5.6 6.7-6.7c6.4-1.1 10 5.2 10 5.2-.2-5 8.5-8 13.2-7.2 5.9 1 8.7 5.3 8.7 5.3 11.6-7 16 4 16 4 2.5-6.2 11.7-4.3 14.6-.6 3.7 4.9-.7 9-.7 9s8 5.4 4.5 11.6c-4 7.1-11.5 2.7-11.5 2.7-3 15-14.6 2-14.6 2-.9 7-4 13-12.8 11-6-1.4-8-9.4-8-9.4-2 8.2-8.4 7-10.8 4.7a5.8 5.8 0 0 1-1.6-3.6C70 67.3 66 58.6 66 58.6c-4.9 3-9.7 2.8-12.2-.5-1.4-1.9-1.8-4-1-7.4-2.1-.6-4.4-2.3-4-6"/><path fill="#fef2e2" d="m128.1 42.8.7.3a1.8 1.8 0 0 1 .8 1l.1.6-.1 1.3a7.8 7.8 0 0 1-.5 1.3c-.4.8-.9 1.5-1.6 2l-.6.2h-.7c-.2 0-.4 0-.7-.2l-.6-.3-.6-.4.1.7.2 1.1-.1 1.2-.3 1-.5 1-.6.7-.4.4-.4.3-.8.5-1 .3c-.3 0-.6 0-1 .2h-2l-1.1-.2c-.4 0-.8-.2-1.1-.3-.4 0-.7-.2-1.1-.3l-1.1-.4-1.1-.5-.1.2.8.9 1 .8 1 .7 1.2.5 1.2.4 1.3.3h1.3l1.4-.3 1.3-.5 1.1-.8.5-.5.4-.6c.3-.3.5-.8.7-1.2l.3-1.3v-1.3c0-.4 0-.8-.2-1.2L125 49l-.5.4 1.5.8 1 .2h1c.6-.2 1.1-.5 1.6-1a5.2 5.2 0 0 0 1.1-1.5 6.2 6.2 0 0 0 .5-2.7l-.1-.9-.5-.9-.7-.7-1-.3h-.4l-.4.1v.2M89.5 33.5l.3-.4.2-.4v-1c0-.3 0-.6-.2-.9L89 30l-.7-.5-.9-.4-1.8-.3-1 .1-.8.3c-.6.3-1.2.7-1.5 1.2l-.5.9-.2.9.1 1.7.5-.3-1-.7-1-.7-1.2-.5-1.4-.2h-1.3l-.7.2-.7.2-1.2.7-1 1-.8 1-.6 1.3-.3 1.3-.1 1.3v1.3l.2 1.2.3 1.2.5 1.2h.2V42l.1-1.2c0-.3 0-.7.2-1 0-.5 0-.8.2-1.2l.3-1 .4-1 .4-1c.3-.2.4-.5.6-.8l.7-.7.8-.6.4-.2.5-.2 1-.2 1-.1c.4 0 .7 0 1.1.2.4 0 .7.2 1 .4l1 .6.6.3v-.6c-.1-.3-.2-.5-.1-.8v-.7l.4-.6.4-.4c.7-.5 1.6-.6 2.5-.7a6 6 0 0 1 1.4.1l1.2.5c.2 0 .4.2.5.3l.4.5.2.6v.8h.1M63.3 54.2l-.5.1a2 2 0 0 1-.6 0l-.3.1h-.4l-.9-.3c-.3 0-.6-.2-.9-.4-.3-.2-.6-.3-.8-.6l-.8-.7c-.2-.3-.5-.5-.6-.8l-.2-.5-.2-.4-.4-.8-.3-.7V49l-.1-.3v-1.2l-.2-.1-.5.4c-.2.1-.3.3-.4.6l-.4 1v1.1l.4 1.2.4.6.4.5 1 .9 1.1.7 1.2.5 1.1.2H62l.4-.1c.3 0 .5-.2.6-.3l.5-.2v-.2M111.8 40.1l.1-.5.2-.6v-.7c0-.3 0-.6-.2-1l-.3-.8-.5-1-.7-.8c-.2-.2-.5-.5-.8-.6l-.4-.3-.4-.2-.7-.5c-.3 0-.5-.2-.7-.3l-.3-.1h-.3l-.6-.2h-.6v-.2s0-.2.4-.5l.6-.3c.3-.2.6-.3 1-.3l1.1.1 1.2.5.5.4.5.5.8 1 .6 1.2.4 1.2.2 1.2-.1 1-.1.5-.2.4-.2.5-.4.5h-.1"/><path fill="#231f20" d="M85.3 88.8c4.2 0 12.3 0 10.8 3.3-.8 1.7-5.1 4-10.7 4.2-5.6 0-10-2.3-10.8-4C73 89 81 88.8 85.3 88.8"/><path fill="#231f20" d="M83.8 95.5c-.4 3.7.5 7.2 3.8 9.2 3.6 2.2 8.2.8 11.6-1 1.3-.7.1-2.6-1.1-2-2.7 1.5-5.6 2.4-8.6 1.4-3.3-1-3.8-4.6-3.5-7.6.2-1.4-2-1.3-2.2 0"/><path fill="#231f20" d="M83.3 96.3c0 2.5-1 5-3 6.4-3.2 2.2-6.3-.3-8.2-2.7-.9-1.1-2.4.4-1.6 1.5 2.3 3 6 5.5 9.9 3.6a9.5 9.5 0 0 0 5-8.8c0-1.4-2.2-1.4-2.1 0"/></svg>
|
After Width: | Height: | Size: 4.1 KiB |
1
frontend/src/assets/logo-full-pride.svg
Normal file
After Width: | Height: | Size: 6.6 KiB |
1
frontend/src/assets/logo-full.svg
Normal file
After Width: | Height: | Size: 5.9 KiB |
12
frontend/src/assets/logo.svg
Normal file
@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 256 256" width="256" height="256">
|
||||
<path d="M2268.2 2512.3a953.7 953.7 0 0 1-50 57c-180.5 189.5-426.2 294-691.6 294A953.7 953.7 0 0 1 847.8 2582a952.7 952.7 0 0 1-281.2-678.8 953.8 953.8 0 0 1 281.2-678.9 953.7 953.7 0 0 1 678.8-281.1 953.7 953.7 0 0 1 678.8 281.1 953.7 953.7 0 0 1 281.2 678.9c0 219.2-78.9 437.2-218.4 609" style="fill:#196aff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:.36633128" transform="matrix(.13333 0 0 -.13333 -75.5 381.8)"/>
|
||||
<path d="M1823.7 1650.9c35.7 104.2 94.7 136.1 102 297 2.6 56.5-14.7 236-14.7 236s28 72-25.8 152.3c-83.5 124.3-255.4 132.8-345.7 132.8-90.3 0-260.2-8.5-343.7-132.8C1142 2256 1170 2184 1170 2184s-9.5-92.4-16.7-173.8c-1.7-19.1.1-94.7 2.4-113a453 453 0 0 1 25.8-96.2c14.4-39.6 36.8-79.9 54-120.5 51.8-122.8 8.4-274.9 11.1-407.3 2.2-94-20-189.3-28.7-281.2a960.4 960.4 0 0 1 308.7-50.6 958.6 958.6 0 0 1 344.9 63.6c-20.4 115-44.1 224.2-47.8 265.9-10.6 125.9-41.3 259.4 0 380" style="fill:#fff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:.36655635" transform="matrix(.13333 0 0 -.13333 -75.5 381.8)"/>
|
||||
<path d="M1162.9 2383.9c1.1-18.8 3-38 8.3-56.2 1.6-5.7 4-19.7 11.4-21.8 9-2.6 25.9 8.3 32.3 13 12.3 9 23.9 18.5 36.2 27.6 8 6 16.5 10.5 24.3 16.5 8.4 6.6 14.7 14.5 21.7 22.2 8.4 9.4 14.8 19 21.3 29.5 5.1 8.2 37.1 13.5 42.2 21 5.6 8.3 1 18.6 1 28.7 0 74.2 4.4 147.6 6.1 220.3 1.8 50 21.4 109.2-53.4 85.8-160.3-50-158.5-271.3-151.4-386.6M1869.1 2279.7c-1.6 1.8-4.2 3.2-6.3 4.8a208 208 0 0 0-25.1 21.5c-9.4 9.6-19.2 19-28.2 28.9-7.9 8.7-17.3 16.6-25 25.6-5.1 6-10 12.3-14.6 18.5-2.3 3.2-3.5 7-5.3 10.4-2.7 5-40 10.1-36.2 15 6.3 8.3 20.3 15.4 23.7 25 17.2 48.6 24.8 244.5 26.8 294.5 5.4 127.8 117.6-6.3 137.2-57.7 57-149.7 23.2-258.8-46.3-386.6" style="fill:#fff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:.36633128" transform="matrix(.13333 0 0 -.13333 -75.5 381.8)"/>
|
||||
<path d="M1716.5 1787.9c-.1 73.8-9.3 103.6-50.4 139.7-25.8 22.6-55.9 31.2-103.8 30-47.9 1.2-82.4-13.4-107.3-39.2-37.5-39-47.4-62-47.5-135.9 0-39.9 43-128.1 55.7-148.5 21.3-36 60.6-48.9 99.1-46.2 38.6-2.7 77.9 10.3 99.1 46.2 12.8 20.4 55.1 107 55 153.9" style="fill:#f1e6d3;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:.36633128" transform="matrix(.13333 0 0 -.13333 -75.5 381.8)"/>
|
||||
<path d="M1226.6 2316c-9.6 86.2-38.6 240 61.5 331.3 11 10.1 14-24.2 15.8-38 2.6-19 0-73.5.4-92.6.7-36.1 8.3-55 4.7-71.5-9.6-45-17.3-42.2-26.5-69.6-18.3-54.4-53.3-83-55.9-59.5M1851.7 2333c10.3-18.2 37 80.3 45.4 123.2 8 40.3 18 93.8 4 133.9-7.4 21.5-53 84.5-58.4 62.9-2-8.5-3.2-71.1-8.3-101.1-6.4-37.1-18-73.8-18-111.6-.2-84.5 25.3-88 35.3-107.2" style="fill:#f1d7d4;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:.36633128" transform="matrix(.13333 0 0 -.13333 -75.5 381.8)"/>
|
||||
<path d="M1522 1319.7c-2.2-6.5-18.6-11.4-24.8-13.3-14.9-4.9-28.1 6.9-36.4 16.8-11.6 13.7-11.3 35.6-16.2 51.6-2.9 9.7-19.5 11-24.5 2-16.6-29.8-81.1 26.4-66.1 45.2 9.9 12.3-13.8 23.2-23.6 11-29-36.1 49-103.4 93.6-85.2 2-9 4-18 8-26.6 7.4-16.9 23.9-27.8 41-37 23.1-12.4 68.2 9.5 75 30.3 4.9 14.5-21.2 19.7-26 5.2M1727.6 1538.2c2.4-10 2.8-44-16-25.4-7.5 7.5-22.6 3-23.2-7-1.4-23.4-24.9-24-45.1-16.9-16 5.6-24.6-16.6-8.6-22.1 29.7-10.4 62-4.6 74.7 17.8 10.1-4.7 21.5-6 30.7 2.6 16 15 18.4 36.2 13.7 55.7-3.5 14.8-29.7 10.1-26.2-4.7M1775 1049.2c-7-14.3-19.8-13.4-33.6-7.4-10.1 4.4-22.6-2.8-19.6-13 6.2-20.6-19.7-26.6-37.3-19.3-15.4 6.5-28.8-13.8-13.2-20.3 31.6-13.2 71.7-1.6 77.5 26.2 20.4-3.3 39.8 2.4 49.4 22.3 6.7 13.6-16.4 25.4-23.2 11.5M1569.8 2153.3c-3.3-20.2-41.1 3.3-50.5 9.7-8.3 5.5-19 2.1-20-7.3-1.4-12.7-18.5-9-26.3-7.4-14.8 3-27.4 12.2-27.7 26-.4 13.6 8.2 27.7 12.6 40.4 2.9 8-8.7 17-17.2 11.5-15.2-9.7-88.7-18.5-59.4 13.6 9.3 10.2-7.1 24.8-16.6 14.5-13.5-14.8-22.6-48.7 6.6-56 15.5-3.7 37.8-3.5 56.8.8-8-25.5-9.6-48.8 23.2-65.1 22.1-11.1 52.5-11 65.4 6 27.2-14.5 69.7-28.7 75.6 7.8 2.1 13-20.4 18.5-22.5 5.5" style="fill:#faeee0;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:.36633128" transform="matrix(.13333 0 0 -.13333 -75.5 381.8)"/>
|
||||
<path d="M1443 1685.6c39.4-3.4 78.8-12.3 118.5-10.9 25.4 1 51.7 4.5 76.8 8.2 18.2 2.7 40.5 6 52.7 19.4 1-45-92.6-59.1-128.9-60-42.1-1-89.5 17.2-119 43.3" style="fill:#494949;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:.36633128" transform="matrix(.13333 0 0 -.13333 -75.5 381.8)"/>
|
||||
<path d="M1549.4 1779.5a353.5 353.5 0 0 1-2.7-87.3c.7-7.6-1.3-25.7 8.8-29.5 8.2-3 18.3 2.7 19.7 10.1 2.2 12.5-3 28.2-3.5 41-.5 14.9 0 29.8 1.6 44.7 1 8.8 5.9 20.7-4.2 27-7.4 4.5-18.3 2.8-19.7-6" style="fill:#494949;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:.36633128" transform="matrix(.13333 0 0 -.13333 -75.5 381.8)"/>
|
||||
<path d="M1626 1849.7c-23.7-1-45.7-14.2-63.4-27-16.1 10.7-40.5 20.5-60.7 14.8-12-3.4-1.1-7.1 4-10.3 9.2-6.2 16.8-14.2 23.7-22.4 10.3-12.6 19.6-25.8 30.7-38 7.6 5.6 15 11.1 21.6 17.6 3.1 3 28.5 37 32.4 42.7 2.4 3.6 5 7.4 7.8 10.8 2.9 3.5 11 9 3.9 11.8" style="fill:#494949;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:.36633128" transform="matrix(.13333 0 0 -.13333 -75.5 381.8)"/>
|
||||
<path d="M1326.5 2010c11.7 30.3 24.3 68.4 56.3 62.4 24.2-5.2 56.7-86.2 36-78.2-11.3 4.4-20.3 41.1-41.4 46-13.4 3-32-43.6-50-48.4-8.7-2.3-4.3 10.4-.9 18.2M1670.6 2010c11.7 30.3 24.2 68.4 56.3 62.4 24.2-5.2 56.7-86.2 35.9-78.2-11.3 4.4-20.2 41.1-41.3 46-13.5 3-32-43.6-50-48.4-8.7-2.3-4.4 10.4-1 18.2" style="fill:#2c3844;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:.36633128" transform="matrix(.13333 0 0 -.13333 -75.5 381.8)"/>
|
||||
</svg>
|
After Width: | Height: | Size: 5.4 KiB |
BIN
frontend/src/assets/no-auth-image.jpg
Normal file
After Width: | Height: | Size: 313 KiB |
64
frontend/src/components/base/BaseButton.story.vue
Normal file
@ -0,0 +1,64 @@
|
||||
<script lang="ts" setup>
|
||||
import {logEvent} from 'histoire/client'
|
||||
import {reactive} from 'vue'
|
||||
import {createRouter, createMemoryHistory} from 'vue-router'
|
||||
import BaseButton from './BaseButton.vue'
|
||||
|
||||
function setupApp({ app }) {
|
||||
// Router mock
|
||||
app.use(createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: { render: () => null } },
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
const state = reactive({
|
||||
disabled: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
:setup-app="setupApp"
|
||||
:layout="{ type: 'grid', width: '200px' }"
|
||||
>
|
||||
<Variant title="custom">
|
||||
<template #controls>
|
||||
<HstCheckbox
|
||||
v-model="state.disabled"
|
||||
title="Disabled"
|
||||
/>
|
||||
</template>
|
||||
<BaseButton :disabled="state.disabled">
|
||||
Hello!
|
||||
</BaseButton>
|
||||
</Variant>
|
||||
|
||||
<Variant title="disabled">
|
||||
<BaseButton disabled>
|
||||
Hello!
|
||||
</BaseButton>
|
||||
</Variant>
|
||||
|
||||
<Variant title="router link">
|
||||
<BaseButton :to="'home'">
|
||||
Hello!
|
||||
</BaseButton>
|
||||
</Variant>
|
||||
|
||||
<Variant title="external link">
|
||||
<BaseButton href="https://vikunja.io">
|
||||
Hello!
|
||||
</BaseButton>
|
||||
</Variant>
|
||||
|
||||
<Variant title="button">
|
||||
<BaseButton @click="logEvent('Click', $event)">
|
||||
Hello!
|
||||
</BaseButton>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
126
frontend/src/components/base/BaseButton.vue
Normal file
@ -0,0 +1,126 @@
|
||||
<!-- a disabled link of any kind is not a link -->
|
||||
<!-- we have a router link -->
|
||||
<!-- just a normal link -->
|
||||
<!-- a button it shall be -->
|
||||
<!-- note that we only pass the click listener here -->
|
||||
<template>
|
||||
<div
|
||||
v-if="disabled === true && (to !== undefined || href !== undefined)"
|
||||
ref="button"
|
||||
class="base-button"
|
||||
:aria-disabled="disabled || undefined"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<router-link
|
||||
v-else-if="to !== undefined"
|
||||
ref="button"
|
||||
:to="to"
|
||||
class="base-button"
|
||||
>
|
||||
<slot />
|
||||
</router-link>
|
||||
<a
|
||||
v-else-if="href !== undefined"
|
||||
ref="button"
|
||||
class="base-button"
|
||||
:href="href"
|
||||
rel="noreferrer noopener nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
<slot />
|
||||
</a>
|
||||
<button
|
||||
v-else
|
||||
ref="button"
|
||||
:type="type"
|
||||
class="base-button base-button--type-button"
|
||||
:disabled="disabled || undefined"
|
||||
@click="(event: MouseEvent) => emit('click', event)"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
const BASE_BUTTON_TYPES_MAP = {
|
||||
BUTTON: 'button',
|
||||
SUBMIT: 'submit',
|
||||
} as const
|
||||
|
||||
export type BaseButtonTypes = typeof BASE_BUTTON_TYPES_MAP[keyof typeof BASE_BUTTON_TYPES_MAP] | undefined
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
// this component removes styling differences between links / vue-router links and button elements
|
||||
// by doing so we make it easy abstract the functionality from style and enable easier and semantic
|
||||
// correct button and link usage. Also see: https://css-tricks.com/a-complete-guide-to-links-and-buttons/#accessibility-considerations
|
||||
|
||||
// the component tries to heuristically determine what it should be checking the props
|
||||
|
||||
// NOTE: Do NOT use buttons with @click to push routes. => Use router-links instead!
|
||||
|
||||
import {unrefElement} from '@vueuse/core'
|
||||
import {ref, type HTMLAttributes} from 'vue'
|
||||
import type {RouteLocationRaw} from 'vue-router'
|
||||
|
||||
export interface BaseButtonProps extends /* @vue-ignore */ HTMLAttributes {
|
||||
type?: BaseButtonTypes
|
||||
disabled?: boolean
|
||||
to?: RouteLocationRaw
|
||||
href?: string
|
||||
}
|
||||
|
||||
export interface BaseButtonEmits {
|
||||
(e: 'click', payload: MouseEvent): void
|
||||
}
|
||||
|
||||
const {
|
||||
type = BASE_BUTTON_TYPES_MAP.BUTTON,
|
||||
disabled = false,
|
||||
} = defineProps<BaseButtonProps>()
|
||||
|
||||
const emit = defineEmits<BaseButtonEmits>()
|
||||
|
||||
const button = ref<HTMLElement | null>(null)
|
||||
|
||||
function focus() {
|
||||
unrefElement(button)?.focus()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focus,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
// NOTE: we do not use scoped styles to reduce specifity and make it easy to overwrite
|
||||
|
||||
// We reset the default styles of a button element to enable easier styling
|
||||
:where(.base-button--type-button) {
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
background-color: transparent;
|
||||
text-align: center;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
:where(.base-button) {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
user-select: none;
|
||||
pointer-events: auto; // disable possible resets
|
||||
|
||||
&:focus, &.is-focused {
|
||||
outline: transparent;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
</style>
|
63
frontend/src/components/base/BaseCheckbox.vue
Normal file
@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div
|
||||
v-cy="'checkbox'"
|
||||
class="base-checkbox"
|
||||
>
|
||||
<input
|
||||
:id="checkboxId"
|
||||
type="checkbox"
|
||||
class="is-sr-only"
|
||||
:checked="modelValue"
|
||||
:disabled="disabled || undefined"
|
||||
@change="(event) => emit('update:modelValue', (event.target as HTMLInputElement).checked)"
|
||||
>
|
||||
|
||||
<slot
|
||||
name="label"
|
||||
:checkbox-id="checkboxId"
|
||||
>
|
||||
<label
|
||||
:for="checkboxId"
|
||||
class="base-checkbox__label"
|
||||
>
|
||||
<slot />
|
||||
</label>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
import {createRandomID} from '@/helpers/randomId'
|
||||
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const checkboxId = ref(`fancycheckbox_${createRandomID()}`)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.base-checkbox__label {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.base-checkbox:has(input:disabled) .base-checkbox__label {
|
||||
cursor:not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
182
frontend/src/components/base/Expandable.vue
Normal file
@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<transition
|
||||
name="expandable-slide"
|
||||
@beforeEnter="beforeEnter"
|
||||
@enter="enter"
|
||||
@afterEnter="afterEnter"
|
||||
@enterCancelled="enterCancelled"
|
||||
@beforeLeave="beforeLeave"
|
||||
@leave="leave"
|
||||
@afterLeave="afterLeave"
|
||||
@leaveCancelled="leaveCancelled"
|
||||
>
|
||||
<div
|
||||
v-if="initialHeight"
|
||||
class="expandable-initial-height"
|
||||
:style="{ maxHeight: `${initialHeight}px` }"
|
||||
:class="{ 'expandable-initial-height--expanded': open }"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="open"
|
||||
class="expandable"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// the logic of this component is loosly based on this article
|
||||
// https://gomakethings.com/how-to-add-transition-animations-to-vanilla-javascript-show-and-hide-methods/#putting-it-all-together
|
||||
|
||||
import {computed, ref} from 'vue'
|
||||
import {getInheritedBackgroundColor} from '@/helpers/getInheritedBackgroundColor'
|
||||
|
||||
const props = defineProps({
|
||||
/** Whether the Expandable is open or not */
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/** If there is too much content, content will be cut of here. */
|
||||
initialHeight: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
/** The hidden content is indicated by a gradient. This is the color that the gradient fades to.
|
||||
* Makes only sense if `initialHeight` is set. */
|
||||
backgroundColor: {
|
||||
type: String,
|
||||
},
|
||||
})
|
||||
|
||||
const wrapper = ref<HTMLElement | null>(null)
|
||||
|
||||
const computedBackgroundColor = computed(() => {
|
||||
if (wrapper.value === null) {
|
||||
return props.backgroundColor || '#fff'
|
||||
}
|
||||
return props.backgroundColor || getInheritedBackgroundColor(wrapper.value)
|
||||
})
|
||||
|
||||
/**
|
||||
* Get the natural height of the element
|
||||
*/
|
||||
function getHeight(el: HTMLElement) {
|
||||
const { display } = el.style // save display property
|
||||
el.style.display = 'block' // Make it visible
|
||||
const height = `${el.scrollHeight}px` // Get its height
|
||||
el.style.display = display // revert to original display property
|
||||
return height
|
||||
}
|
||||
|
||||
/**
|
||||
* force layout of element changes
|
||||
* https://gist.github.com/paulirish/5d52fb081b3570c81e3a
|
||||
*/
|
||||
function forceLayout(el: HTMLElement) {
|
||||
el.offsetTop
|
||||
}
|
||||
|
||||
/* ######################################################################
|
||||
# The following functions are called by the js hooks of the transitions.
|
||||
# They follow the orignal hook order of the vue transition component
|
||||
# see: https://vuejs.org/guide/built-ins/transition.html#javascript-hooks
|
||||
###################################################################### */
|
||||
|
||||
function beforeEnter(el: HTMLElement) {
|
||||
el.style.height = '0'
|
||||
el.style.willChange = 'height'
|
||||
el.style.backfaceVisibility = 'hidden'
|
||||
forceLayout(el)
|
||||
}
|
||||
|
||||
// the done callback is optional when
|
||||
// used in combination with CSS
|
||||
function enter(el: HTMLElement) {
|
||||
const height = getHeight(el) // Get the natural height
|
||||
el.style.height = height // Update the height
|
||||
}
|
||||
|
||||
function afterEnter(el: HTMLElement) {
|
||||
removeHeight(el)
|
||||
}
|
||||
|
||||
function enterCancelled(el: HTMLElement) {
|
||||
removeHeight(el)
|
||||
}
|
||||
|
||||
function beforeLeave(el: HTMLElement) {
|
||||
// Give the element a height to change from
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
forceLayout(el)
|
||||
}
|
||||
|
||||
function leave(el: HTMLElement) {
|
||||
// Set the height back to 0
|
||||
el.style.height = '0'
|
||||
el.style.willChange = ''
|
||||
el.style.backfaceVisibility = ''
|
||||
}
|
||||
|
||||
function afterLeave(el: HTMLElement) {
|
||||
removeHeight(el)
|
||||
}
|
||||
|
||||
function leaveCancelled(el: HTMLElement) {
|
||||
removeHeight(el)
|
||||
}
|
||||
|
||||
function removeHeight(el: HTMLElement) {
|
||||
el.style.height = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$transition-time: 300ms;
|
||||
|
||||
.expandable-slide-enter-active,
|
||||
.expandable-slide-leave-active {
|
||||
transition:
|
||||
opacity $transition-time ease-in-quint,
|
||||
height $transition-time ease-in-out-quint;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expandable-slide-enter,
|
||||
.expandable-slide-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.expandable-initial-height {
|
||||
padding: 5px;
|
||||
margin: -5px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
ease-in-out
|
||||
v-bind(computedBackgroundColor)
|
||||
);
|
||||
position: absolute;
|
||||
height: 40px;
|
||||
width: 100%;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.expandable-initial-height--expanded {
|
||||
height: 100% !important;
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
21
frontend/src/components/date/dateRanges.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export const DATE_RANGES = {
|
||||
// Format:
|
||||
// Key is the title, as a translation string, the first entry of the value array
|
||||
// is the "from" date, the second one is the "to" date.
|
||||
'today': ['now/d', 'now/d+1d'],
|
||||
|
||||
'lastWeek': ['now/w-1w', 'now/w-2w'],
|
||||
'thisWeek': ['now/w', 'now/w+1w'],
|
||||
'restOfThisWeek': ['now', 'now/w+1w'],
|
||||
'nextWeek': ['now/w+1w', 'now/w+2w'],
|
||||
'next7Days': ['now', 'now+7d'],
|
||||
|
||||
'lastMonth': ['now/M-1M', 'now/M-2M'],
|
||||
'thisMonth': ['now/M', 'now/M+1M'],
|
||||
'restOfThisMonth': ['now', 'now/M+1M'],
|
||||
'nextMonth': ['now/M+1M', 'now/M+2M'],
|
||||
'next30Days': ['now', 'now+30d'],
|
||||
|
||||
'thisYear': ['now/y', 'now/y+1y'],
|
||||
'restOfThisYear': ['now', 'now/y+1y'],
|
||||
}
|
11
frontend/src/components/date/datemathHelp.story.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import datemathHelp from './datemathHelp.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story>
|
||||
<Variant title="Default">
|
||||
<datemathHelp />
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
145
frontend/src/components/date/datemathHelp.vue
Normal file
@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<card
|
||||
class="has-no-shadow how-it-works-modal"
|
||||
:title="$t('input.datemathHelp.title')"
|
||||
>
|
||||
<p>
|
||||
{{ $t('input.datemathHelp.intro') }}
|
||||
</p>
|
||||
<p>
|
||||
<i18n-t
|
||||
keypath="input.datemathHelp.expression"
|
||||
scope="global"
|
||||
>
|
||||
<code>now</code>
|
||||
<code>||</code>
|
||||
</i18n-t>
|
||||
</p>
|
||||
<p>
|
||||
<i18n-t
|
||||
keypath="input.datemathHelp.similar"
|
||||
scope="global"
|
||||
>
|
||||
<BaseButton
|
||||
href="https://grafana.com/docs/grafana/latest/dashboards/time-range-controls/"
|
||||
target="_blank"
|
||||
>
|
||||
Grafana
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
href="https://www.elastic.co/guide/en/elasticsearch/reference/7.3/common-options.html#date-math"
|
||||
target="_blank"
|
||||
>
|
||||
Elasticsearch
|
||||
</BaseButton>
|
||||
</i18n-t>
|
||||
</p>
|
||||
<p>{{ $t('misc.forExample') }}</p>
|
||||
<ul>
|
||||
<li><code>+1d</code> {{ $t('input.datemathHelp.add1Day') }}</li>
|
||||
<li><code>-1d</code> {{ $t('input.datemathHelp.minus1Day') }}</li>
|
||||
<li><code>/d</code> {{ $t('input.datemathHelp.roundDay') }}</li>
|
||||
</ul>
|
||||
<h3>{{ $t('input.datemathHelp.supportedUnits') }}</h3>
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>s</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.seconds') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>m</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.minutes') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>h</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.hours') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>H</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.hours') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>d</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.days') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>w</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.weeks') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>M</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.months') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>y</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.years') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>{{ $t('input.datemathHelp.someExamples') }}</h3>
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>now</code></td>
|
||||
<td>{{ $t('input.datemathHelp.examples.now') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>now+24h</code></td>
|
||||
<td>{{ $t('input.datemathHelp.examples.in24h') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>now/d</code></td>
|
||||
<td>{{ $t('input.datemathHelp.examples.today') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>now/w</code></td>
|
||||
<td>{{ $t('input.datemathHelp.examples.beginningOfThisWeek') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>now/w+1w</code></td>
|
||||
<td>{{ $t('input.datemathHelp.examples.endOfThisWeek') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>now+30d</code></td>
|
||||
<td>{{ $t('input.datemathHelp.examples.in30Days') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{{ exampleDate }}||+1M/d</code></td>
|
||||
<td>
|
||||
<i18n-t
|
||||
keypath="input.datemathHelp.examples.datePlusMonth"
|
||||
scope="global"
|
||||
>
|
||||
<strong>{{ exampleDate }}</strong>
|
||||
</i18n-t>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {formatDateShort} from '@/helpers/time/formatDate'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
const exampleDate = formatDateShort(new Date())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// FIXME: Remove style overwrites
|
||||
.how-it-works-modal {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
.base-button {
|
||||
display: inline;
|
||||
}
|
||||
</style>
|
302
frontend/src/components/date/datepickerWithRange.vue
Normal file
@ -0,0 +1,302 @@
|
||||
<template>
|
||||
<div class="datepicker-with-range-container">
|
||||
<Popup>
|
||||
<template #trigger="{toggle}">
|
||||
<slot
|
||||
name="trigger"
|
||||
:toggle="toggle"
|
||||
:button-text="buttonText"
|
||||
/>
|
||||
</template>
|
||||
<template #content="{isOpen}">
|
||||
<div
|
||||
class="datepicker-with-range"
|
||||
:class="{'is-open': isOpen}"
|
||||
>
|
||||
<div class="selections">
|
||||
<BaseButton
|
||||
:class="{'is-active': customRangeActive}"
|
||||
@click="setDateRange(null)"
|
||||
>
|
||||
{{ $t('misc.custom') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-for="(value, text) in DATE_RANGES"
|
||||
:key="text"
|
||||
:class="{'is-active': from === value[0] && to === value[1]}"
|
||||
@click="setDateRange(value)"
|
||||
>
|
||||
{{ $t(`input.datepickerRange.ranges.${text}`) }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
<div class="flatpickr-container input-group">
|
||||
<label class="label">
|
||||
{{ $t('input.datepickerRange.from') }}
|
||||
<div class="field has-addons">
|
||||
<div class="control is-fullwidth">
|
||||
<input
|
||||
v-model="from"
|
||||
class="input"
|
||||
type="text"
|
||||
>
|
||||
</div>
|
||||
<div class="control">
|
||||
<x-button
|
||||
icon="calendar"
|
||||
variant="secondary"
|
||||
data-toggle
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="label">
|
||||
{{ $t('input.datepickerRange.to') }}
|
||||
<div class="field has-addons">
|
||||
<div class="control is-fullwidth">
|
||||
<input
|
||||
v-model="to"
|
||||
class="input"
|
||||
type="text"
|
||||
>
|
||||
</div>
|
||||
<div class="control">
|
||||
<x-button
|
||||
icon="calendar"
|
||||
variant="secondary"
|
||||
data-toggle
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<flat-pickr
|
||||
v-model="flatpickrRange"
|
||||
:config="flatPickerConfig"
|
||||
/>
|
||||
|
||||
<p>
|
||||
{{ $t('input.datemathHelp.canuse') }}
|
||||
<BaseButton
|
||||
class="has-text-primary"
|
||||
@click="showHowItWorks = true"
|
||||
>
|
||||
{{ $t('input.datemathHelp.learnhow') }}
|
||||
</BaseButton>
|
||||
</p>
|
||||
|
||||
<modal
|
||||
:enabled="showHowItWorks"
|
||||
transition-name="fade"
|
||||
:overflow="true"
|
||||
variant="hint-modal"
|
||||
@close="() => showHowItWorks = false"
|
||||
>
|
||||
<DatemathHelp />
|
||||
</modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, ref, watch} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import flatPickr from 'vue-flatpickr-component'
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
|
||||
|
||||
import Popup from '@/components/misc/popup.vue'
|
||||
import {DATE_RANGES} from '@/components/date/dateRanges'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import DatemathHelp from '@/components/date/datemathHelp.vue'
|
||||
import { getFlatpickrLanguage } from '@/helpers/flatpickrLanguage'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const flatPickerConfig = computed(() => ({
|
||||
altFormat: t('date.altFormatLong'),
|
||||
altInput: true,
|
||||
dateFormat: 'Y-m-d H:i',
|
||||
enableTime: false,
|
||||
wrap: true,
|
||||
mode: 'range',
|
||||
locale: getFlatpickrLanguage(),
|
||||
}))
|
||||
|
||||
const showHowItWorks = ref(false)
|
||||
|
||||
const flatpickrRange = ref('')
|
||||
|
||||
const from = ref('')
|
||||
const to = ref('')
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
newValue => {
|
||||
from.value = newValue.dateFrom
|
||||
to.value = newValue.dateTo
|
||||
// Only set the date back to flatpickr when it's an actual date.
|
||||
// Otherwise flatpickr runs in an endless loop and slows down the browser.
|
||||
const dateFrom = parseDateOrString(from.value, false)
|
||||
const dateTo = parseDateOrString(to.value, false)
|
||||
if (dateFrom instanceof Date && dateTo instanceof Date) {
|
||||
flatpickrRange.value = `${from.value} to ${to.value}`
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function emitChanged() {
|
||||
const args = {
|
||||
dateFrom: from.value === '' ? null : from.value,
|
||||
dateTo: to.value === '' ? null : to.value,
|
||||
}
|
||||
emit('update:modelValue', args)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => flatpickrRange.value,
|
||||
(newVal: string | null) => {
|
||||
if (newVal === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const [fromDate, toDate] = newVal.split(' to ')
|
||||
|
||||
if (typeof fromDate === 'undefined' || typeof toDate === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
from.value = fromDate
|
||||
to.value = toDate
|
||||
|
||||
emitChanged()
|
||||
},
|
||||
)
|
||||
watch(() => from.value, emitChanged)
|
||||
watch(() => to.value, emitChanged)
|
||||
|
||||
function setDateRange(range: string[] | null) {
|
||||
if (range === null) {
|
||||
from.value = ''
|
||||
to.value = ''
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
from.value = range[0]
|
||||
to.value = range[1]
|
||||
}
|
||||
|
||||
const customRangeActive = computed<boolean>(() => {
|
||||
return !Object.values(DATE_RANGES).some(range => from.value === range[0] && to.value === range[1])
|
||||
})
|
||||
|
||||
const buttonText = computed<string>(() => {
|
||||
if (from.value !== '' && to.value !== '') {
|
||||
return t('input.datepickerRange.fromto', {
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
})
|
||||
}
|
||||
|
||||
return t('task.show.select')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.datepicker-with-range-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
:deep(.popup) {
|
||||
z-index: 10;
|
||||
margin-top: 1rem;
|
||||
border-radius: $radius;
|
||||
border: 1px solid var(--grey-200);
|
||||
background-color: var(--white);
|
||||
box-shadow: $shadow;
|
||||
|
||||
&.is-open {
|
||||
width: 500px;
|
||||
height: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.datepicker-with-range {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
:deep(.flatpickr-calendar) {
|
||||
margin: 0 auto 8px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.flatpickr-container {
|
||||
width: 70%;
|
||||
border-left: 1px solid var(--grey-200);
|
||||
padding: 1rem;
|
||||
font-size: .9rem;
|
||||
|
||||
// Flatpickr has no option to use it without an input field so we're hiding it instead
|
||||
:deep(input.form-control.input) {
|
||||
height: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.field .control :deep(.button) {
|
||||
border: 1px solid var(--input-border-color);
|
||||
height: 2.25rem;
|
||||
|
||||
&:hover {
|
||||
border: 1px solid var(--input-hover-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.label, .input, :deep(.button) {
|
||||
font-size: .9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.selections {
|
||||
width: 30%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: .5rem;
|
||||
overflow-y: scroll;
|
||||
|
||||
button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: .5rem 1rem;
|
||||
transition: $transition;
|
||||
font-size: .9rem;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&.is-active {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&:hover, &.is-active {
|
||||
background-color: var(--grey-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
86
frontend/src/components/home/AddToHomeScreen.vue
Normal file
@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="shouldShowMessage"
|
||||
class="add-to-home-screen"
|
||||
:class="{'has-update-available': hasUpdateAvailable}"
|
||||
>
|
||||
<icon
|
||||
icon="arrow-up-from-bracket"
|
||||
class="add-icon"
|
||||
/>
|
||||
<p>
|
||||
{{ $t('home.addToHomeScreen') }}
|
||||
</p>
|
||||
<BaseButton
|
||||
class="hide-button"
|
||||
@click="() => hideMessage = true"
|
||||
>
|
||||
<icon icon="x" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import {useLocalStorage} from '@vueuse/core'
|
||||
import {computed} from 'vue'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
|
||||
const hideMessage = useLocalStorage('hideAddToHomeScreenMessage', false)
|
||||
const hasUpdateAvailable = computed(() => baseStore.updateAvailable)
|
||||
|
||||
const shouldShowMessage = computed(() => {
|
||||
if (hideMessage.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && window.matchMedia('(display-mode: standalone)').matches) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.add-to-home-screen {
|
||||
position: fixed;
|
||||
// FIXME: We should prevent usage of z-index or
|
||||
// at least define it centrally
|
||||
// the highest z-index of a modal is .hint-modal with 4500
|
||||
z-index: 5000;
|
||||
bottom: 1rem;
|
||||
inset-inline: 1rem;
|
||||
max-width: max-content;
|
||||
margin-inline: auto;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: .5rem 1rem;
|
||||
background: var(--grey-900);
|
||||
border-radius: $radius;
|
||||
font-size: .9rem;
|
||||
color: var(--grey-200);
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.has-update-available {
|
||||
bottom: 5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.add-icon {
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.hide-button {
|
||||
padding: .25rem .5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
52
frontend/src/components/home/DemoMode.vue
Normal file
@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, ref} from 'vue'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const hide = ref(false)
|
||||
const enabled = computed(() => configStore.demoModeEnabled && !hide.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="enabled"
|
||||
class="demo-mode-banner"
|
||||
>
|
||||
<p>
|
||||
{{ $t('demo.title') }}
|
||||
<strong class="is-uppercase">{{ $t('demo.everythingWillBeDeleted') }}</strong>
|
||||
</p>
|
||||
<BaseButton
|
||||
class="hide-button"
|
||||
@click="() => hide = true"
|
||||
>
|
||||
<icon icon="times" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.demo-mode-banner {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--danger);
|
||||
z-index: 100;
|
||||
padding: .5rem;
|
||||
text-align: center;
|
||||
|
||||
&, strong {
|
||||
color: hsl(220, 13%, 91%) !important; // --grey-200 in light mode, hardcoded because the color should not change
|
||||
}
|
||||
}
|
||||
|
||||
.hide-button {
|
||||
padding: .25rem .5rem;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: .5rem;
|
||||
top: .25rem;
|
||||
}
|
||||
</style>
|
38
frontend/src/components/home/Logo.vue
Normal file
@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useNow } from '@vueuse/core'
|
||||
|
||||
import LogoFull from '@/assets/logo-full.svg?component'
|
||||
import LogoFullPride from '@/assets/logo-full-pride.svg?component'
|
||||
import {MILLISECONDS_A_HOUR} from '@/constants/date'
|
||||
|
||||
const now = useNow({
|
||||
interval: MILLISECONDS_A_HOUR,
|
||||
})
|
||||
const Logo = computed(() => window.ALLOW_ICON_CHANGES && now.value.getMonth() === 6 ? LogoFullPride : LogoFull)
|
||||
const CustomLogo = computed(() => window.CUSTOM_LOGO_URL)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Logo
|
||||
v-if="!CustomLogo"
|
||||
alt="Vikunja"
|
||||
class="logo"
|
||||
/>
|
||||
<img
|
||||
v-show="CustomLogo"
|
||||
:src="CustomLogo"
|
||||
alt="Vikunja"
|
||||
class="logo"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.logo {
|
||||
color: var(--logo-text-color);
|
||||
max-width: 168px;
|
||||
max-height: 48px;
|
||||
}
|
||||
</style>
|
74
frontend/src/components/home/MenuButton.vue
Normal file
@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<BaseButton
|
||||
v-shortcut="'Mod+e'"
|
||||
class="menu-show-button"
|
||||
:title="$t('keyboardShortcuts.toggleMenu')"
|
||||
:aria-label="menuActive ? $t('misc.hideMenu') : $t('misc.showMenu')"
|
||||
@click="baseStore.toggleMenu()"
|
||||
@shortkey="() => baseStore.toggleMenu()"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const menuActive = computed(() => baseStore.menuActive)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$lineWidth: 2rem;
|
||||
$size: $lineWidth + 1rem;
|
||||
|
||||
.menu-show-button {
|
||||
min-height: $size;
|
||||
width: $size;
|
||||
|
||||
position: relative;
|
||||
|
||||
$transformX: translateX(-50%);
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
height: 3px;
|
||||
width: $lineWidth;
|
||||
left: 50%;
|
||||
transform: $transformX;
|
||||
background-color: var(--grey-400);
|
||||
border-radius: 2px;
|
||||
transition: all $transition;
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: 50%;
|
||||
transform: $transformX translateY(-0.4rem)
|
||||
}
|
||||
|
||||
&::after {
|
||||
bottom: 50%;
|
||||
transform: $transformX translateY(0.4rem)
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
&::before,
|
||||
&::after {
|
||||
background-color: var(--grey-600);
|
||||
}
|
||||
|
||||
&::before {
|
||||
transform: $transformX translateY(-0.5rem);
|
||||
}
|
||||
|
||||
&::after {
|
||||
transform: $transformX translateY(0.5rem)
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
25
frontend/src/components/home/PoweredByLink.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<BaseButton
|
||||
class="menu-bottom-link"
|
||||
:href="poweredByUrl"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('misc.poweredBy') }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import {POWERED_BY as poweredByUrl} from '@/urls'
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.menu-bottom-link {
|
||||
color: var(--grey-300);
|
||||
text-align: center;
|
||||
display: block;
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
font-size: .8rem;
|
||||
}
|
||||
</style>
|
109
frontend/src/components/home/ProjectsNavigation.vue
Normal file
@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<draggable
|
||||
v-model="availableProjects"
|
||||
animation="100"
|
||||
ghost-class="ghost"
|
||||
group="projects"
|
||||
handle=".handle"
|
||||
tag="menu"
|
||||
item-key="id"
|
||||
:disabled="!canEditOrder"
|
||||
filter=".drag-disabled"
|
||||
:component-data="{
|
||||
type: 'transition-group',
|
||||
name: !drag ? 'flip-list' : null,
|
||||
class: [
|
||||
'menu-list can-be-hidden',
|
||||
{ 'dragging-disabled': !canEditOrder }
|
||||
],
|
||||
}"
|
||||
@start="() => drag = true"
|
||||
@end="saveProjectPosition"
|
||||
>
|
||||
<template #item="{element: project}">
|
||||
<ProjectsNavigationItem
|
||||
:class="{'drag-disabled': project.id < 0}"
|
||||
:project="project"
|
||||
:is-loading="projectUpdating[project.id]"
|
||||
:can-collapse="canCollapse"
|
||||
:level="level"
|
||||
:data-project-id="project.id"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref, watch} from 'vue'
|
||||
import draggable from 'zhyswan-vuedraggable'
|
||||
import type {SortableEvent} from 'sortablejs'
|
||||
|
||||
import ProjectsNavigationItem from '@/components/home/ProjectsNavigationItem.vue'
|
||||
|
||||
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: IProject[],
|
||||
canEditOrder: boolean,
|
||||
canCollapse?: boolean,
|
||||
level?: number,
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', projects: IProject[]): void
|
||||
}>()
|
||||
|
||||
const drag = ref(false)
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
// Vue draggable will modify the projects list as it changes their position which will not work on a prop.
|
||||
// Hence, we'll clone the prop and work on the clone.
|
||||
const availableProjects = ref<IProject[]>([])
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
projects => {
|
||||
availableProjects.value = projects || []
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
const projectUpdating = ref<{ [id: IProject['id']]: boolean }>({})
|
||||
|
||||
async function saveProjectPosition(e: SortableEvent) {
|
||||
if (!e.newIndex && e.newIndex !== 0) return
|
||||
|
||||
const projectsActive = availableProjects.value
|
||||
// If the project was dragged to the last position, Safari will report e.newIndex as the size of the projectsActive
|
||||
// array instead of using the position. Because the index is wrong in that case, dragging the project will fail.
|
||||
// To work around that we're explicitly checking that case here and decrease the index.
|
||||
const newIndex = e.newIndex === projectsActive.length ? e.newIndex - 1 : e.newIndex
|
||||
|
||||
const projectId = parseInt(e.item.dataset.projectId)
|
||||
const project = projectStore.projects[projectId]
|
||||
|
||||
const parentProjectId = e.to.parentNode.dataset.projectId ? parseInt(e.to.parentNode.dataset.projectId) : 0
|
||||
const projectBefore = projectsActive[newIndex - 1] ?? null
|
||||
const projectAfter = projectsActive[newIndex + 1] ?? null
|
||||
projectUpdating.value[project.id] = true
|
||||
|
||||
const position = calculateItemPosition(
|
||||
projectBefore !== null ? projectBefore.position : null,
|
||||
projectAfter !== null ? projectAfter.position : null,
|
||||
)
|
||||
|
||||
try {
|
||||
// create a copy of the project in order to not violate pinia manipulation
|
||||
await projectStore.updateProject({
|
||||
...project,
|
||||
position,
|
||||
parentProjectId,
|
||||
})
|
||||
emit('update:modelValue', availableProjects.value)
|
||||
} finally {
|
||||
projectUpdating.value[project.id] = false
|
||||
}
|
||||
}
|
||||
</script>
|
199
frontend/src/components/home/ProjectsNavigationItem.vue
Normal file
@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<li
|
||||
class="list-menu loader-container is-loading-small"
|
||||
:class="{'is-loading': isLoading}"
|
||||
>
|
||||
<div>
|
||||
<BaseButton
|
||||
v-if="canCollapse && childProjects?.length > 0"
|
||||
class="collapse-project-button"
|
||||
@click="childProjectsOpen = !childProjectsOpen"
|
||||
>
|
||||
<icon
|
||||
icon="chevron-down"
|
||||
:class="{ 'project-is-collapsed': !childProjectsOpen }"
|
||||
/>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
:to="{ name: 'project.index', params: { projectId: project.id} }"
|
||||
class="list-menu-link"
|
||||
:class="{'router-link-exact-active': currentProject?.id === project.id}"
|
||||
>
|
||||
<span
|
||||
v-if="!canCollapse || childProjects?.length === 0"
|
||||
class="collapse-project-button-placeholder"
|
||||
/>
|
||||
<div
|
||||
class="color-bubble-handle-wrapper"
|
||||
:class="{'is-draggable': project.id > 0}"
|
||||
>
|
||||
<ColorBubble
|
||||
v-if="project.hexColor !== ''"
|
||||
:color="project.hexColor"
|
||||
/>
|
||||
<span
|
||||
v-else-if="project.id < -1"
|
||||
class="saved-filter-icon icon menu-item-icon"
|
||||
>
|
||||
<icon icon="filter" />
|
||||
</span>
|
||||
<span
|
||||
v-if="project.id > 0"
|
||||
class="icon menu-item-icon handle"
|
||||
:class="{'has-color-bubble': project.hexColor !== ''}"
|
||||
>
|
||||
<icon icon="grip-lines" />
|
||||
</span>
|
||||
</div>
|
||||
<span class="project-menu-title">{{ getProjectTitle(project) }}</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="project.id > 0"
|
||||
class="favorite"
|
||||
:class="{'is-favorite': project.isFavorite}"
|
||||
@click="projectStore.toggleProjectFavorite(project)"
|
||||
>
|
||||
<icon :icon="project.isFavorite ? 'star' : ['far', 'star']" />
|
||||
</BaseButton>
|
||||
<ProjectSettingsDropdown
|
||||
class="menu-list-dropdown"
|
||||
:project="project"
|
||||
:level="level"
|
||||
>
|
||||
<template #trigger="{toggleOpen}">
|
||||
<BaseButton
|
||||
class="menu-list-dropdown-trigger"
|
||||
@click="toggleOpen"
|
||||
>
|
||||
<icon
|
||||
icon="ellipsis-h"
|
||||
class="icon"
|
||||
/>
|
||||
</BaseButton>
|
||||
</template>
|
||||
</ProjectSettingsDropdown>
|
||||
</div>
|
||||
<ProjectsNavigation
|
||||
v-if="canNestDeeper && childProjectsOpen && canCollapse"
|
||||
:model-value="childProjects"
|
||||
:can-edit-order="true"
|
||||
:can-collapse="canCollapse"
|
||||
:level="level + 1"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref} from 'vue'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import ProjectSettingsDropdown from '@/components/project/project-settings-dropdown.vue'
|
||||
import {getProjectTitle} from '@/helpers/getProjectTitle'
|
||||
import ColorBubble from '@/components/misc/colorBubble.vue'
|
||||
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
|
||||
import {canNestProjectDeeper} from '@/helpers/canNestProjectDeeper'
|
||||
|
||||
const {
|
||||
project,
|
||||
isLoading,
|
||||
canCollapse,
|
||||
level = 0,
|
||||
} = defineProps<{
|
||||
project: IProject,
|
||||
isLoading?: boolean,
|
||||
canCollapse?: boolean,
|
||||
level?: number,
|
||||
}>()
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
const baseStore = useBaseStore()
|
||||
const currentProject = computed(() => baseStore.currentProject)
|
||||
|
||||
const childProjectsOpen = ref(true)
|
||||
|
||||
const childProjects = computed(() => {
|
||||
if (!canNestDeeper.value) {
|
||||
return []
|
||||
}
|
||||
|
||||
return projectStore.getChildProjects(project.id)
|
||||
.filter(p => !p.isArchived)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
})
|
||||
|
||||
const canNestDeeper = computed(() => canNestProjectDeeper(level))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.list-setting-spacer {
|
||||
width: 5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.project-is-collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.favorite {
|
||||
transition: opacity $transition, color $transition;
|
||||
opacity: 0;
|
||||
|
||||
&:hover,
|
||||
&.is-favorite {
|
||||
opacity: 1;
|
||||
color: var(--warning);
|
||||
}
|
||||
}
|
||||
|
||||
.list-menu:hover > div > .favorite {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.list-menu:hover > div > a > .color-bubble-handle-wrapper.is-draggable > {
|
||||
.saved-filter-icon,
|
||||
.color-bubble {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.is-touch .color-bubble {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.color-bubble-handle-wrapper {
|
||||
position: relative;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
margin-right: .25rem;
|
||||
flex-shrink: 0;
|
||||
|
||||
.color-bubble, .icon {
|
||||
transition: all $transition;
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.project-menu-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.saved-filter-icon {
|
||||
color: var(--grey-300) !important;
|
||||
font-size: .75rem;
|
||||
}
|
||||
|
||||
.is-touch .handle.has-color-bubble {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
288
frontend/src/components/home/TheNavigation.vue
Normal file
@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<header
|
||||
:class="{ 'has-background': background, 'menu-active': menuActive }"
|
||||
aria-label="main navigation"
|
||||
class="navbar d-print-none"
|
||||
>
|
||||
<router-link
|
||||
:to="{ name: 'home' }"
|
||||
class="logo-link"
|
||||
>
|
||||
<Logo
|
||||
width="164"
|
||||
height="48"
|
||||
/>
|
||||
</router-link>
|
||||
|
||||
<MenuButton class="menu-button" />
|
||||
|
||||
<div
|
||||
v-if="currentProject?.id"
|
||||
class="project-title-wrapper"
|
||||
>
|
||||
<h1 class="project-title">
|
||||
{{ currentProject.title === '' ? $t('misc.loading') : getProjectTitle(currentProject) }}
|
||||
</h1>
|
||||
|
||||
<BaseButton
|
||||
:to="{ name: 'project.info', params: { projectId: currentProject.id } }"
|
||||
class="project-title-button"
|
||||
>
|
||||
<icon icon="circle-info" />
|
||||
</BaseButton>
|
||||
|
||||
<ProjectSettingsDropdown
|
||||
v-if="canWriteCurrentProject && currentProject.id !== -1"
|
||||
class="project-title-dropdown"
|
||||
:project="currentProject"
|
||||
>
|
||||
<template #trigger="{ toggleOpen }">
|
||||
<BaseButton
|
||||
class="project-title-button"
|
||||
@click="toggleOpen"
|
||||
>
|
||||
<icon
|
||||
icon="ellipsis-h"
|
||||
class="icon"
|
||||
/>
|
||||
</BaseButton>
|
||||
</template>
|
||||
</ProjectSettingsDropdown>
|
||||
</div>
|
||||
|
||||
<div class="navbar-end">
|
||||
<OpenQuickActions />
|
||||
<Notifications />
|
||||
<Dropdown>
|
||||
<template #trigger="{ toggleOpen, open }">
|
||||
<BaseButton
|
||||
class="username-dropdown-trigger"
|
||||
variant="secondary"
|
||||
:shadow="false"
|
||||
@click="toggleOpen"
|
||||
>
|
||||
<img
|
||||
:src="authStore.avatarUrl"
|
||||
alt=""
|
||||
class="avatar"
|
||||
width="40"
|
||||
height="40"
|
||||
>
|
||||
<span class="username">{{ authStore.userDisplayName }}</span>
|
||||
<span
|
||||
class="icon is-small"
|
||||
:style="{
|
||||
transform: open ? 'rotate(180deg)' : 'rotate(0)',
|
||||
}"
|
||||
>
|
||||
<icon icon="chevron-down" />
|
||||
</span>
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<DropdownItem :to="{ name: 'user.settings' }">
|
||||
{{ $t('user.settings.title') }}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
v-if="imprintUrl"
|
||||
:href="imprintUrl"
|
||||
>
|
||||
{{ $t('navigation.imprint') }}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
v-if="privacyPolicyUrl"
|
||||
:href="privacyPolicyUrl"
|
||||
>
|
||||
{{ $t('navigation.privacy') }}
|
||||
</DropdownItem>
|
||||
<DropdownItem @click="baseStore.setKeyboardShortcutsActive(true)">
|
||||
{{ $t('keyboardShortcuts.title') }}
|
||||
</DropdownItem>
|
||||
<DropdownItem :to="{ name: 'about' }">
|
||||
{{ $t('about.title') }}
|
||||
</DropdownItem>
|
||||
<DropdownItem @click="authStore.logout()">
|
||||
{{ $t('user.auth.logout') }}
|
||||
</DropdownItem>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { RIGHTS as Rights } from '@/constants/rights'
|
||||
|
||||
import ProjectSettingsDropdown from '@/components/project/project-settings-dropdown.vue'
|
||||
import Dropdown from '@/components/misc/dropdown.vue'
|
||||
import DropdownItem from '@/components/misc/dropdown-item.vue'
|
||||
import Notifications from '@/components/notifications/notifications.vue'
|
||||
import Logo from '@/components/home/Logo.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import MenuButton from '@/components/home/MenuButton.vue'
|
||||
import OpenQuickActions from '@/components/misc/OpenQuickActions.vue'
|
||||
|
||||
import { getProjectTitle } from '@/helpers/getProjectTitle'
|
||||
|
||||
import { useBaseStore } from '@/stores/base'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const currentProject = computed(() => baseStore.currentProject)
|
||||
const background = computed(() => baseStore.background)
|
||||
const canWriteCurrentProject = computed(() => baseStore.currentProject?.maxRight > Rights.READ)
|
||||
const menuActive = computed(() => baseStore.menuActive)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const imprintUrl = computed(() => configStore.legal.imprintUrl)
|
||||
const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$user-dropdown-width-mobile: 5rem;
|
||||
|
||||
.navbar {
|
||||
--navbar-button-min-width: 40px;
|
||||
--navbar-gap-width: 1rem;
|
||||
--navbar-icon-size: 1.25rem;
|
||||
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--navbar-gap-width);
|
||||
|
||||
background: var(--site-background);
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
padding-right: .5rem;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
padding-left: 2rem;
|
||||
padding-right: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
&.menu-active {
|
||||
@media screen and (max-width: $tablet) {
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: notifications should provide a slot for the icon instead, so that we can style it as we want
|
||||
:deep() {
|
||||
.trigger-button {
|
||||
color: var(--grey-400);
|
||||
font-size: var(--navbar-icon-size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logo-link {
|
||||
display: none;
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-button {
|
||||
margin-right: auto;
|
||||
align-self: stretch;
|
||||
flex: 0 0 auto;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.project-title-wrapper {
|
||||
margin-inline: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
// this makes the truncated text of the project title work
|
||||
// inside the flexbox parent
|
||||
min-width: 0;
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
padding-inline: var(--navbar-gap-width);
|
||||
}
|
||||
}
|
||||
|
||||
.project-title {
|
||||
font-size: 1rem;
|
||||
// We need the following for overflowing ellipsis to work
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.project-title-dropdown {
|
||||
align-self: stretch;
|
||||
|
||||
.project-title-button {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.project-title-button {
|
||||
align-self: stretch;
|
||||
min-width: var(--navbar-button-min-width);
|
||||
display: flex;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--navbar-icon-size);
|
||||
color: var(--grey-400);
|
||||
}
|
||||
|
||||
.navbar-end {
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
|
||||
>* {
|
||||
min-width: var(--navbar-button-min-width);
|
||||
}
|
||||
}
|
||||
|
||||
.username-dropdown-trigger {
|
||||
padding-left: 1rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
text-transform: uppercase;
|
||||
font-size: .85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-family: $vikunja-font;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
border-radius: 100%;
|
||||
vertical-align: middle;
|
||||
height: 40px;
|
||||
margin-right: .5rem;
|
||||
}
|
||||
</style>
|
82
frontend/src/components/home/UpdateNotification.vue
Normal file
@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="updateAvailable"
|
||||
class="update-notification"
|
||||
>
|
||||
<p class="update-notification__message">
|
||||
{{ $t('update.available') }}
|
||||
</p>
|
||||
<x-button
|
||||
:shadow="false"
|
||||
:wrap="false"
|
||||
@click="refreshApp()"
|
||||
>
|
||||
{{ $t('update.do') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, ref} from 'vue'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
|
||||
const updateAvailable = computed(() => baseStore.updateAvailable)
|
||||
const registration = ref(null)
|
||||
const refreshing = ref(false)
|
||||
|
||||
document.addEventListener('swUpdated', showRefreshUI, {once: true})
|
||||
|
||||
navigator?.serviceWorker?.addEventListener(
|
||||
'controllerchange', () => {
|
||||
if (refreshing.value) return
|
||||
refreshing.value = true
|
||||
window.location.reload()
|
||||
},
|
||||
)
|
||||
|
||||
function showRefreshUI(e: Event) {
|
||||
console.log('recieved refresh event', e)
|
||||
registration.value = e.detail
|
||||
baseStore.setUpdateAvailable(true)
|
||||
}
|
||||
|
||||
function refreshApp() {
|
||||
baseStore.setUpdateAvailable(false)
|
||||
if (!registration.value || !registration.value.waiting) {
|
||||
return
|
||||
}
|
||||
// Notify the service worker to actually do the update
|
||||
registration.value.waiting.postMessage('skipWaiting')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.update-notification {
|
||||
position: fixed;
|
||||
// FIXME: We should prevent usage of z-index or
|
||||
// at least define it centrally
|
||||
// the highest z-index of a modal is .hint-modal with 4500
|
||||
z-index: 5000;
|
||||
bottom: 1rem;
|
||||
inset-inline: 1rem;
|
||||
max-width: max-content;
|
||||
margin-inline: auto;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: .5rem .5rem .5rem 1rem;
|
||||
background: $warning;
|
||||
border-radius: $radius;
|
||||
font-size: .9rem;
|
||||
color: hsl(220.9, 39.3%, 11%); // color copied to avoid it changing in dark mode
|
||||
}
|
||||
|
||||
.update-notification__message {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
233
frontend/src/components/home/contentAuth.vue
Normal file
@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<div class="content-auth">
|
||||
<BaseButton
|
||||
v-show="menuActive"
|
||||
class="menu-hide-button d-print-none"
|
||||
@click="baseStore.setMenuActive(false)"
|
||||
>
|
||||
<icon icon="times" />
|
||||
</BaseButton>
|
||||
<div
|
||||
class="app-container"
|
||||
:class="{'has-background': background || blurHash}"
|
||||
:style="{'background-image': blurHash && `url(${blurHash})`}"
|
||||
>
|
||||
<div
|
||||
:class="{'is-visible': background}"
|
||||
class="app-container-background background-fade-in d-print-none"
|
||||
:style="{'background-image': background && `url(${background})`}"
|
||||
/>
|
||||
<Navigation class="d-print-none" />
|
||||
<main
|
||||
class="app-content"
|
||||
:class="[
|
||||
{ 'is-menu-enabled': menuActive },
|
||||
$route.name,
|
||||
]"
|
||||
>
|
||||
<BaseButton
|
||||
v-show="menuActive"
|
||||
class="mobile-overlay d-print-none"
|
||||
@click="baseStore.setMenuActive(false)"
|
||||
/>
|
||||
|
||||
<QuickActions />
|
||||
|
||||
<router-view
|
||||
v-slot="{ Component }"
|
||||
:route="routeWithModal"
|
||||
>
|
||||
<keep-alive :include="['project.list', 'project.gantt', 'project.table', 'project.kanban']">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
|
||||
<modal
|
||||
:enabled="typeof currentModal !== 'undefined'"
|
||||
variant="scrolling"
|
||||
class="task-detail-view-modal"
|
||||
@close="closeModal()"
|
||||
>
|
||||
<component :is="currentModal" />
|
||||
</modal>
|
||||
|
||||
<BaseButton
|
||||
v-shortcut="'?'"
|
||||
class="keyboard-shortcuts-button d-print-none"
|
||||
@click="showKeyboardShortcuts()"
|
||||
>
|
||||
<icon icon="keyboard" />
|
||||
</BaseButton>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {watch, computed} from 'vue'
|
||||
import {useRoute} from 'vue-router'
|
||||
|
||||
import Navigation from '@/components/home/navigation.vue'
|
||||
import QuickActions from '@/components/quick-actions/quick-actions.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useLabelStore} from '@/stores/labels'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
import {useRouteWithModal} from '@/composables/useRouteWithModal'
|
||||
import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus'
|
||||
|
||||
const {routeWithModal, currentModal, closeModal} = useRouteWithModal()
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const background = computed(() => baseStore.background)
|
||||
const blurHash = computed(() => baseStore.blurHash)
|
||||
const menuActive = computed(() => baseStore.menuActive)
|
||||
|
||||
function showKeyboardShortcuts() {
|
||||
baseStore.setKeyboardShortcutsActive(true)
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// FIXME: this is really error prone
|
||||
// Reset the current project highlight in menu if the current route is not project related.
|
||||
watch(() => route.name as string, (routeName) => {
|
||||
if (
|
||||
routeName &&
|
||||
(
|
||||
[
|
||||
'home',
|
||||
'teams.index',
|
||||
'teams.edit',
|
||||
'tasks.range',
|
||||
'labels.index',
|
||||
'migrate.start',
|
||||
'migrate.wunderlist',
|
||||
'projects.index',
|
||||
].includes(routeName) ||
|
||||
routeName.startsWith('user.settings')
|
||||
)
|
||||
) {
|
||||
baseStore.handleSetCurrentProject({project: null})
|
||||
}
|
||||
})
|
||||
|
||||
// TODO: Reset the title if the page component does not set one itself
|
||||
|
||||
useRenewTokenOnFocus()
|
||||
|
||||
const labelStore = useLabelStore()
|
||||
labelStore.loadAllLabels()
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
projectStore.loadProjects()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.menu-hide-button {
|
||||
position: fixed;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
z-index: 31;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 2rem;
|
||||
color: var(--grey-400);
|
||||
line-height: 1;
|
||||
transition: all $transition;
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: var(--grey-600);
|
||||
}
|
||||
}
|
||||
|
||||
.app-container {
|
||||
min-height: calc(100vh - 65px);
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
padding-top: $navbar-height;
|
||||
}
|
||||
}
|
||||
|
||||
.app-content {
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
padding: 1.5rem 0.5rem 1rem;
|
||||
// TODO refactor: DRY `transition-timing-function` with `./navigation.vue`.
|
||||
transition: margin-left $transition-duration;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
margin-left: 0;
|
||||
min-height: calc(100vh - 4rem);
|
||||
}
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
padding: $navbar-height + 1.5rem 1.5rem 1rem 1.5rem;
|
||||
}
|
||||
|
||||
&.is-menu-enabled {
|
||||
@media screen and (min-width: $tablet) {
|
||||
margin-left: $navbar-width;
|
||||
}
|
||||
}
|
||||
|
||||
// Used to make sure the spinner is always in the middle while loading
|
||||
> .loader-container {
|
||||
min-height: calc(100vh - #{$navbar-height + 1.5rem + 1rem});
|
||||
}
|
||||
|
||||
// FIXME: This should be somehow defined inside Card.vue
|
||||
.card {
|
||||
background: var(--white);
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background: hsla(var(--grey-100-hsl), 0.8);
|
||||
z-index: 5;
|
||||
opacity: 0;
|
||||
transition: all $transition;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.keyboard-shortcuts-button {
|
||||
position: fixed;
|
||||
bottom: calc(1rem - 4px);
|
||||
right: 1rem;
|
||||
z-index: 4500; // The modal has a z-index of 4000
|
||||
|
||||
color: var(--grey-500);
|
||||
transition: color $transition;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.content-auth {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
68
frontend/src/components/home/contentLinkShare.vue
Normal file
@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[background ? 'has-background' : '', $route.name as string +'-view']"
|
||||
:style="{'background-image': `url(${background})`}"
|
||||
class="link-share-container"
|
||||
>
|
||||
<div class="container has-text-centered link-share-view">
|
||||
<div class="column is-10 is-offset-1">
|
||||
<Logo
|
||||
v-if="logoVisible"
|
||||
class="logo"
|
||||
/>
|
||||
<h1
|
||||
:class="{'m-0': !logoVisible}"
|
||||
:style="{ 'opacity': currentProject?.title === '' ? '0': '1' }"
|
||||
class="title"
|
||||
>
|
||||
{{ currentProject?.title === '' ? $t('misc.loading') : currentProject?.title }}
|
||||
</h1>
|
||||
<div class="box has-text-left view">
|
||||
<router-view />
|
||||
<PoweredByLink />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed} from 'vue'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
|
||||
import Logo from '@/components/home/Logo.vue'
|
||||
import PoweredByLink from './PoweredByLink.vue'
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const currentProject = computed(() => baseStore.currentProject)
|
||||
const background = computed(() => baseStore.background)
|
||||
const logoVisible = computed(() => baseStore.logoVisible)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.link-share-container.has-background .view {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.logo {
|
||||
max-width: 300px;
|
||||
width: 90%;
|
||||
margin: 2rem 0 1.5rem;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.column {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-shadow: 0 0 1rem var(--white);
|
||||
}
|
||||
|
||||
// FIXME: this should be defined somewhere deep
|
||||
.link-share-view .card {
|
||||
background-color: var(--white);
|
||||
}
|
||||
</style>
|
193
frontend/src/components/home/navigation.vue
Normal file
@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<aside
|
||||
:class="{'is-active': baseStore.menuActive}"
|
||||
class="menu-container"
|
||||
>
|
||||
<nav class="menu top-menu">
|
||||
<router-link
|
||||
:to="{name: 'home'}"
|
||||
class="logo"
|
||||
>
|
||||
<Logo
|
||||
width="164"
|
||||
height="48"
|
||||
/>
|
||||
</router-link>
|
||||
<menu class="menu-list other-menu-items">
|
||||
<li>
|
||||
<router-link
|
||||
v-shortcut="'g o'"
|
||||
:to="{ name: 'home'}"
|
||||
>
|
||||
<span class="menu-item-icon icon">
|
||||
<icon icon="calendar" />
|
||||
</span>
|
||||
{{ $t('navigation.overview') }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
v-shortcut="'g u'"
|
||||
:to="{ name: 'tasks.range'}"
|
||||
>
|
||||
<span class="menu-item-icon icon">
|
||||
<icon :icon="['far', 'calendar-alt']" />
|
||||
</span>
|
||||
{{ $t('navigation.upcoming') }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
v-shortcut="'g p'"
|
||||
:to="{ name: 'projects.index'}"
|
||||
>
|
||||
<span class="menu-item-icon icon">
|
||||
<icon icon="layer-group" />
|
||||
</span>
|
||||
{{ $t('project.projects') }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
v-shortcut="'g a'"
|
||||
:to="{ name: 'labels.index'}"
|
||||
>
|
||||
<span class="menu-item-icon icon">
|
||||
<icon icon="tags" />
|
||||
</span>
|
||||
{{ $t('label.title') }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
v-shortcut="'g m'"
|
||||
:to="{ name: 'teams.index'}"
|
||||
>
|
||||
<span class="menu-item-icon icon">
|
||||
<icon icon="users" />
|
||||
</span>
|
||||
{{ $t('team.title') }}
|
||||
</router-link>
|
||||
</li>
|
||||
</menu>
|
||||
</nav>
|
||||
|
||||
<Loading
|
||||
v-if="projectStore.isLoading"
|
||||
variant="small"
|
||||
/>
|
||||
<template v-else>
|
||||
<nav
|
||||
v-if="favoriteProjects"
|
||||
class="menu"
|
||||
>
|
||||
<ProjectsNavigation
|
||||
:model-value="favoriteProjects"
|
||||
:can-edit-order="false"
|
||||
:can-collapse="false"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<nav
|
||||
v-if="savedFilterProjects"
|
||||
class="menu"
|
||||
>
|
||||
<ProjectsNavigation
|
||||
:model-value="savedFilterProjects"
|
||||
:can-edit-order="false"
|
||||
:can-collapse="false"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<nav class="menu">
|
||||
<ProjectsNavigation
|
||||
:model-value="projects"
|
||||
:can-edit-order="true"
|
||||
:can-collapse="true"
|
||||
:level="1"
|
||||
/>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<PoweredByLink />
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
|
||||
import PoweredByLink from '@/components/home/PoweredByLink.vue'
|
||||
import Logo from '@/components/home/Logo.vue'
|
||||
import Loading from '@/components/misc/loading.vue'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
const projects = computed(() => projectStore.notArchivedRootProjects)
|
||||
const favoriteProjects = computed(() => projectStore.favoriteProjects)
|
||||
const savedFilterProjects = computed(() => projectStore.savedFilterProjects)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.logo {
|
||||
display: block;
|
||||
|
||||
padding-left: 1rem;
|
||||
margin-right: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-container {
|
||||
background: var(--site-background);
|
||||
color: $vikunja-nav-color;
|
||||
padding: 1rem 0;
|
||||
transition: transform $transition-duration ease-in;
|
||||
position: fixed;
|
||||
top: $navbar-height;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
transform: translateX(-100%);
|
||||
overflow-x: auto;
|
||||
width: $navbar-width;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
top: 0;
|
||||
width: 70vw;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
transform: translateX(0);
|
||||
transition: transform $transition-duration ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
.top-menu .menu-list {
|
||||
li {
|
||||
font-weight: 600;
|
||||
font-family: $vikunja-font;
|
||||
}
|
||||
|
||||
.list-menu-link,
|
||||
li > a {
|
||||
padding-left: 2rem;
|
||||
display: inline-block;
|
||||
|
||||
.icon {
|
||||
padding-bottom: .25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu + .menu {
|
||||
padding-top: math.div($navbar-padding, 2);
|
||||
}
|
||||
</style>
|
5
frontend/src/components/input/AsyncEditor.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import {createAsyncComponent} from '@/helpers/createAsyncComponent'
|
||||
|
||||
const TipTap = createAsyncComponent(() => import('@/components/input/editor/TipTap.vue'))
|
||||
|
||||
export default TipTap
|
35
frontend/src/components/input/Button.story.vue
Normal file
@ -0,0 +1,35 @@
|
||||
<script lang="ts" setup>
|
||||
import {logEvent} from 'histoire/client'
|
||||
import XButton from './button.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story :layout="{ type: 'grid', width: '200px' }">
|
||||
<Variant title="primary">
|
||||
<XButton
|
||||
variant="primary"
|
||||
@click="logEvent('Click', $event)"
|
||||
>
|
||||
Order pizza!
|
||||
</XButton>
|
||||
</Variant>
|
||||
|
||||
<Variant title="secondary">
|
||||
<XButton
|
||||
variant="secondary"
|
||||
@click="logEvent('Click', $event)"
|
||||
>
|
||||
Order spaghetti!
|
||||
</XButton>
|
||||
</Variant>
|
||||
|
||||
<Variant title="tertiary">
|
||||
<XButton
|
||||
variant="tertiary"
|
||||
@click="logEvent('Click', $event)"
|
||||
>
|
||||
Order tortellini!
|
||||
</XButton>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
14
frontend/src/components/input/ColorPicker.story.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
import {reactive} from 'vue'
|
||||
import ColorPicker from './ColorPicker.vue'
|
||||
|
||||
const state = reactive({
|
||||
color: '#f2f2f2',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story :layout="{ type: 'grid', width: '200px' }">
|
||||
<ColorPicker v-model="state.color" />
|
||||
</Story>
|
||||
</template>
|
187
frontend/src/components/input/ColorPicker.vue
Normal file
@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div class="color-picker-container">
|
||||
<datalist :id="colorListID">
|
||||
<option
|
||||
v-for="defaultColor in defaultColors"
|
||||
:key="defaultColor"
|
||||
:value="defaultColor"
|
||||
/>
|
||||
</datalist>
|
||||
|
||||
<div class="picker">
|
||||
<input
|
||||
v-model="color"
|
||||
class="picker__input"
|
||||
type="color"
|
||||
:list="colorListID"
|
||||
:class="{'is-empty': isEmpty}"
|
||||
>
|
||||
<svg
|
||||
v-show="isEmpty"
|
||||
class="picker__pattern"
|
||||
viewBox="0 0 22 22"
|
||||
fill="fff"
|
||||
>
|
||||
<pattern
|
||||
id="checker"
|
||||
width="11"
|
||||
height="11"
|
||||
patternUnits="userSpaceOnUse"
|
||||
fill="FFF"
|
||||
>
|
||||
<rect
|
||||
fill="#cccccc"
|
||||
x="0"
|
||||
width="5.5"
|
||||
height="5.5"
|
||||
y="0"
|
||||
/>
|
||||
<rect
|
||||
fill="#cccccc"
|
||||
x="5.5"
|
||||
width="5.5"
|
||||
height="5.5"
|
||||
y="5.5"
|
||||
/>
|
||||
</pattern>
|
||||
<rect
|
||||
width="22"
|
||||
height="22"
|
||||
fill="url(#checker)"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<XButton
|
||||
v-if="!isEmpty"
|
||||
:disabled="isEmpty"
|
||||
class="is-small ml-2"
|
||||
:shadow="false"
|
||||
variant="secondary"
|
||||
@click="reset"
|
||||
>
|
||||
{{ $t('input.resetColor') }}
|
||||
</XButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, watch} from 'vue'
|
||||
import {createRandomID} from '@/helpers/randomId'
|
||||
import XButton from '@/components/input/button.vue'
|
||||
|
||||
const {
|
||||
modelValue,
|
||||
} = defineProps<{
|
||||
modelValue: string,
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const DEFAULT_COLORS = [
|
||||
'#1973ff',
|
||||
'#7F23FF',
|
||||
'#ff4136',
|
||||
'#ff851b',
|
||||
'#ffeb10',
|
||||
'#00db60',
|
||||
]
|
||||
|
||||
const color = ref('')
|
||||
const lastChangeTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
const defaultColors = ref(DEFAULT_COLORS)
|
||||
const colorListID = ref(createRandomID())
|
||||
|
||||
watch(
|
||||
() => modelValue,
|
||||
(newValue) => {
|
||||
if (newValue === '' || newValue.startsWith('var(')) {
|
||||
color.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (!newValue.startsWith('#') && (newValue.length === 6 || newValue.length === 3)) {
|
||||
newValue = `#${newValue}`
|
||||
}
|
||||
|
||||
color.value = newValue
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
watch(color, () => update())
|
||||
|
||||
const isEmpty = computed(() => color.value === '#000000' || color.value === '')
|
||||
|
||||
function update(force = false) {
|
||||
if(isEmpty.value && !force) {
|
||||
return
|
||||
}
|
||||
|
||||
if (lastChangeTimeout.value !== null) {
|
||||
clearTimeout(lastChangeTimeout.value)
|
||||
}
|
||||
|
||||
lastChangeTimeout.value = setTimeout(() => {
|
||||
emit('update:modelValue', color.value)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
// FIXME: I havn't found a way to make it clear to the user the color war reset.
|
||||
// Not sure if verte is capable of this - it does not show the change when setting this.color = ''
|
||||
color.value = ''
|
||||
update(true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.color-picker-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
// reset / see https://stackoverflow.com/a/11471224/15522256
|
||||
input[type="color"] {
|
||||
-webkit-appearance: none;
|
||||
border: none;
|
||||
}
|
||||
input[type="color"]::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
input[type="color"]::-webkit-color-swatch {
|
||||
border: none;
|
||||
}
|
||||
|
||||
$PICKER_SIZE: 24px;
|
||||
$BORDER_WIDTH: 1px;
|
||||
.picker {
|
||||
display: grid;
|
||||
width: $PICKER_SIZE;
|
||||
height: $PICKER_SIZE;
|
||||
overflow: hidden;
|
||||
border-radius: 100%;
|
||||
border: $BORDER_WIDTH solid var(--grey-300);
|
||||
box-shadow: $shadow;
|
||||
|
||||
& > * {
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
}
|
||||
}
|
||||
|
||||
input.picker__input {
|
||||
padding: 0;
|
||||
width: $PICKER_SIZE - 2 * $BORDER_WIDTH;
|
||||
height: $PICKER_SIZE - 2 * $BORDER_WIDTH;
|
||||
}
|
||||
|
||||
.picker__input.is-empty {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.picker__pattern {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
</style>
|
74
frontend/src/components/input/SelectProject.vue
Normal file
@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<Multiselect
|
||||
v-model="selectedProjects"
|
||||
:search-results="foundProjects"
|
||||
:loading="projectService.loading"
|
||||
:multiple="true"
|
||||
:placeholder="$t('project.search')"
|
||||
label="title"
|
||||
@search="findProjects"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, shallowReactive, watchEffect, type PropType} from 'vue'
|
||||
|
||||
import Multiselect from '@/components/input/multiselect.vue'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
import ProjectService from '@/services/project'
|
||||
import {includesById} from '@/helpers/utils'
|
||||
|
||||
type ProjectFilterFunc = (p: IProject) => boolean
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as PropType<IProject[]>,
|
||||
default: () => [],
|
||||
},
|
||||
projectFilter: {
|
||||
type: Function as PropType<ProjectFilterFunc>,
|
||||
default: () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
return (_: IProject) => true
|
||||
},
|
||||
},
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: IProject[]): void
|
||||
}>()
|
||||
|
||||
const projects = ref<IProject[]>([])
|
||||
|
||||
watchEffect(() => {
|
||||
projects.value = props.modelValue
|
||||
})
|
||||
|
||||
const selectedProjects = computed({
|
||||
get() {
|
||||
return projects.value
|
||||
},
|
||||
set: (value) => {
|
||||
projects.value = value
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
|
||||
const projectService = shallowReactive(new ProjectService())
|
||||
const foundProjects = ref<IProject[]>([])
|
||||
|
||||
async function findProjects(query: string) {
|
||||
if (query === '') {
|
||||
foundProjects.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const response = await projectService.getAll({}, {s: query}) as IProject[]
|
||||
|
||||
// Filter selected items from the results
|
||||
foundProjects.value = response
|
||||
.filter(({id}) => !includesById(projects.value, id))
|
||||
.filter(props.projectFilter)
|
||||
}
|
||||
</script>
|
63
frontend/src/components/input/SelectUser.vue
Normal file
@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<Multiselect
|
||||
v-model="selectedUsers"
|
||||
:search-results="foundUsers"
|
||||
:loading="userService.loading"
|
||||
:multiple="true"
|
||||
:placeholder="$t('team.edit.search')"
|
||||
label="username"
|
||||
@search="findUsers"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, shallowReactive, watchEffect, type PropType} from 'vue'
|
||||
|
||||
import Multiselect from '@/components/input/multiselect.vue'
|
||||
|
||||
import type {IUser} from '@/modelTypes/IUser'
|
||||
|
||||
import UserService from '@/services/user'
|
||||
import {includesById} from '@/helpers/utils'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as PropType<IUser[]>,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: IUser[]): void
|
||||
}>()
|
||||
|
||||
const users = ref<IUser[]>([])
|
||||
|
||||
watchEffect(() => {
|
||||
users.value = props.modelValue
|
||||
})
|
||||
|
||||
const selectedUsers = computed({
|
||||
get() {
|
||||
return users.value
|
||||
},
|
||||
set: (value) => {
|
||||
users.value = value
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
|
||||
const userService = shallowReactive(new UserService())
|
||||
const foundUsers = ref<IUser[]>([])
|
||||
|
||||
async function findUsers(query: string) {
|
||||
if (query === '') {
|
||||
foundUsers.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const response = await userService.getAll({}, {s: query}) as IUser[]
|
||||
|
||||
// Filter selected items from the results
|
||||
foundUsers.value = response.filter(({id}) => !includesById(users.value, id))
|
||||
}
|
||||
</script>
|
26
frontend/src/components/input/SimpleButton.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<BaseButton class="simple-button">
|
||||
<slot />
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.simple-button {
|
||||
color: var(--text);
|
||||
padding: .25rem .5rem;
|
||||
transition: background-color $transition;
|
||||
border-radius: $radius;
|
||||
display: block;
|
||||
margin: .1rem 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: var(--white);
|
||||
}
|
||||
}
|
||||
</style>
|
119
frontend/src/components/input/button.vue
Normal file
@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<BaseButton
|
||||
class="button"
|
||||
:class="[
|
||||
variantClass,
|
||||
{
|
||||
'is-loading': loading,
|
||||
'has-no-shadow': !shadow || variant === 'tertiary',
|
||||
}
|
||||
]"
|
||||
:style="{
|
||||
'--button-white-space': wrap ? 'break-spaces' : 'nowrap',
|
||||
}"
|
||||
>
|
||||
<template v-if="icon">
|
||||
<icon
|
||||
v-if="showIconOnly"
|
||||
:icon="icon"
|
||||
:style="{'color': iconColor !== '' ? iconColor : undefined}"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="icon is-small"
|
||||
>
|
||||
<icon
|
||||
:icon="icon"
|
||||
:style="{'color': iconColor !== '' ? iconColor : undefined}"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
<slot />
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
const BUTTON_TYPES_MAP = {
|
||||
primary: 'is-primary',
|
||||
secondary: 'is-outlined',
|
||||
tertiary: 'is-text is-inverted underline-none',
|
||||
} as const
|
||||
|
||||
export type ButtonTypes = keyof typeof BUTTON_TYPES_MAP
|
||||
|
||||
export default { name: 'XButton' }
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, useSlots} from 'vue'
|
||||
import BaseButton, {type BaseButtonProps} from '@/components/base/BaseButton.vue'
|
||||
import type { IconProp } from '@fortawesome/fontawesome-svg-core'
|
||||
|
||||
// extending the props of the BaseButton
|
||||
export interface ButtonProps extends /* @vue-ignore */ BaseButtonProps {
|
||||
variant?: ButtonTypes
|
||||
icon?: IconProp
|
||||
iconColor?: string
|
||||
loading?: boolean
|
||||
shadow?: boolean
|
||||
wrap?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
variant = 'primary',
|
||||
icon = '',
|
||||
iconColor = '',
|
||||
loading = false,
|
||||
shadow = true,
|
||||
wrap = true,
|
||||
} = defineProps<ButtonProps>()
|
||||
|
||||
const variantClass = computed(() => BUTTON_TYPES_MAP[variant])
|
||||
|
||||
const slots = useSlots()
|
||||
const showIconOnly = computed(() => icon !== '' && typeof slots.default === 'undefined')
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.button {
|
||||
transition: all $transition;
|
||||
border: 0;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
height: auto;
|
||||
min-height: $button-height;
|
||||
box-shadow: var(--shadow-sm);
|
||||
display: inline-flex;
|
||||
white-space: var(--button-white-space);
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
&.fullheight {
|
||||
padding-right: 7px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&.is-active,
|
||||
&.is-focused,
|
||||
&:active,
|
||||
&:focus,
|
||||
&:focus:not(:active) {
|
||||
box-shadow: var(--shadow-xs) !important;
|
||||
}
|
||||
|
||||
&.is-primary.is-outlined:hover {
|
||||
color: var(--white);
|
||||
}
|
||||
}
|
||||
|
||||
.is-small {
|
||||
border-radius: $radius;
|
||||
}
|
||||
|
||||
.underline-none {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
</style>
|
153
frontend/src/components/input/datepicker.vue
Normal file
@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div class="datepicker">
|
||||
<SimpleButton
|
||||
class="show"
|
||||
:disabled="disabled || undefined"
|
||||
@click.stop="toggleDatePopup"
|
||||
>
|
||||
{{ date === null ? chooseDateLabel : formatDateShort(date) }}
|
||||
</SimpleButton>
|
||||
|
||||
<CustomTransition name="fade">
|
||||
<div
|
||||
v-if="show"
|
||||
ref="datepickerPopup"
|
||||
class="datepicker-popup"
|
||||
>
|
||||
<DatepickerInline
|
||||
v-model="date"
|
||||
@update:modelValue="updateData"
|
||||
/>
|
||||
|
||||
<x-button
|
||||
v-cy="'closeDatepicker'"
|
||||
class="datepicker__close-button"
|
||||
:shadow="false"
|
||||
@click="close"
|
||||
>
|
||||
{{ $t('misc.confirm') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</CustomTransition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, onMounted, onBeforeUnmount, toRef, watch, type PropType} from 'vue'
|
||||
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
import DatepickerInline from '@/components/input/datepickerInline.vue'
|
||||
import SimpleButton from '@/components/input/SimpleButton.vue'
|
||||
|
||||
import {formatDateShort} from '@/helpers/time/formatDate'
|
||||
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
|
||||
import {createDateFromString} from '@/helpers/time/createDateFromString'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [Date, null, String] as PropType<Date | null | string>,
|
||||
validator: prop => prop instanceof Date || prop === null || typeof prop === 'string',
|
||||
default: null,
|
||||
},
|
||||
chooseDateLabel: {
|
||||
type: String,
|
||||
default() {
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
return t('input.datepicker.chooseDate')
|
||||
},
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'closeOnChange'])
|
||||
|
||||
const date = ref<Date | null>()
|
||||
const show = ref(false)
|
||||
const changed = ref(false)
|
||||
|
||||
onMounted(() => document.addEventListener('click', hideDatePopup))
|
||||
onBeforeUnmount(() =>document.removeEventListener('click', hideDatePopup))
|
||||
|
||||
const modelValue = toRef(props, 'modelValue')
|
||||
watch(
|
||||
modelValue,
|
||||
setDateValue,
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
function setDateValue(dateString: string | Date | null) {
|
||||
if (dateString === null) {
|
||||
date.value = null
|
||||
return
|
||||
}
|
||||
date.value = createDateFromString(dateString)
|
||||
}
|
||||
|
||||
function updateData() {
|
||||
changed.value = true
|
||||
emit('update:modelValue', date.value)
|
||||
}
|
||||
|
||||
function toggleDatePopup() {
|
||||
if (props.disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
show.value = !show.value
|
||||
}
|
||||
|
||||
const datepickerPopup = ref<HTMLElement | null>(null)
|
||||
function hideDatePopup(e: MouseEvent) {
|
||||
if (show.value) {
|
||||
closeWhenClickedOutside(e, datepickerPopup.value, close)
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
// Kind of dirty, but the timeout allows us to enter a time and click on "confirm" without
|
||||
// having to click on another input field before it is actually used.
|
||||
setTimeout(() => {
|
||||
show.value = false
|
||||
emit('close', changed.value)
|
||||
if (changed.value) {
|
||||
changed.value = false
|
||||
emit('closeOnChange', changed.value)
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.datepicker {
|
||||
input.input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.datepicker-popup {
|
||||
position: absolute;
|
||||
z-index: 99;
|
||||
width: 320px;
|
||||
background: var(--white);
|
||||
border-radius: $radius;
|
||||
box-shadow: $shadow;
|
||||
|
||||
@media screen and (max-width: ($tablet)) {
|
||||
width: calc(100vw - 5rem);
|
||||
}
|
||||
}
|
||||
|
||||
.datepicker__close-button {
|
||||
margin: 1rem;
|
||||
width: calc(100% - 2rem);
|
||||
}
|
||||
|
||||
:deep(.flatpickr-calendar) {
|
||||
margin: 0 auto 8px;
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
225
frontend/src/components/input/datepickerInline.vue
Normal file
@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<BaseButton
|
||||
v-if="(new Date()).getHours() < 21"
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('today')"
|
||||
>
|
||||
<span class="icon"><icon :icon="['far', 'calendar-alt']" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.today') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('today') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('tomorrow')"
|
||||
>
|
||||
<span class="icon"><icon :icon="['far', 'sun']" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.tomorrow') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('tomorrow') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('nextMonday')"
|
||||
>
|
||||
<span class="icon"><icon icon="coffee" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.nextMonday') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextMonday') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('thisWeekend')"
|
||||
>
|
||||
<span class="icon"><icon icon="cocktail" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.thisWeekend') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('thisWeekend') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('laterThisWeek')"
|
||||
>
|
||||
<span class="icon"><icon icon="chess-knight" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.laterThisWeek') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('laterThisWeek') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('nextWeek')"
|
||||
>
|
||||
<span class="icon"><icon icon="forward" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.nextWeek') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextWeek') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
|
||||
<div class="flatpickr-container">
|
||||
<flat-pickr
|
||||
v-model="flatPickrDate"
|
||||
:config="flatPickerConfig"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref, toRef, watch, computed, type PropType} from 'vue'
|
||||
import flatPickr from 'vue-flatpickr-component'
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
import {formatDate} from '@/helpers/time/formatDate'
|
||||
import {calculateDayInterval} from '@/helpers/time/calculateDayInterval'
|
||||
import {calculateNearestHours} from '@/helpers/time/calculateNearestHours'
|
||||
import {createDateFromString} from '@/helpers/time/createDateFromString'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import { getFlatpickrLanguage } from '@/helpers/flatpickrLanguage'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [Date, null, String] as PropType<Date | null | string>,
|
||||
validator: prop => prop instanceof Date || prop === null || typeof prop === 'string',
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'close-on-change'])
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const date = ref<Date | null>()
|
||||
const changed = ref(false)
|
||||
|
||||
const modelValue = toRef(props, 'modelValue')
|
||||
watch(
|
||||
modelValue,
|
||||
setDateValue,
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
const flatPickerConfig = computed(() => ({
|
||||
altFormat: t('date.altFormatLong'),
|
||||
altInput: true,
|
||||
dateFormat: 'Y-m-d H:i',
|
||||
enableTime: true,
|
||||
time_24hr: true,
|
||||
inline: true,
|
||||
locale: getFlatpickrLanguage(),
|
||||
}))
|
||||
|
||||
// Since flatpickr dates are strings, we need to convert them to native date objects.
|
||||
// To make that work, we need a separate variable since flatpickr does not have a change event.
|
||||
const flatPickrDate = computed({
|
||||
set(newValue: string | Date | null) {
|
||||
if (newValue === null) {
|
||||
date.value = null
|
||||
return
|
||||
}
|
||||
|
||||
if (date.value !== null) {
|
||||
const oldDate = formatDate(date.value, 'yyy-LL-dd H:mm')
|
||||
if (oldDate === newValue) {
|
||||
return
|
||||
}
|
||||
}
|
||||
date.value = createDateFromString(newValue)
|
||||
updateData()
|
||||
},
|
||||
get() {
|
||||
if (!date.value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return formatDate(date.value, 'yyy-LL-dd H:mm')
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
function setDateValue(dateString: string | Date | null) {
|
||||
if (dateString === null) {
|
||||
date.value = null
|
||||
return
|
||||
}
|
||||
date.value = createDateFromString(dateString)
|
||||
}
|
||||
|
||||
function updateData() {
|
||||
changed.value = true
|
||||
emit('update:modelValue', date.value)
|
||||
}
|
||||
|
||||
function setDate(dateString: string) {
|
||||
const interval = calculateDayInterval(dateString)
|
||||
const newDate = new Date()
|
||||
newDate.setDate(newDate.getDate() + interval)
|
||||
newDate.setHours(calculateNearestHours(newDate))
|
||||
newDate.setMinutes(0)
|
||||
newDate.setSeconds(0)
|
||||
date.value = newDate
|
||||
updateData()
|
||||
}
|
||||
|
||||
function getWeekdayFromStringInterval(dateString: string) {
|
||||
const interval = calculateDayInterval(dateString)
|
||||
const newDate = new Date()
|
||||
newDate.setDate(newDate.getDate() + interval)
|
||||
return formatDate(newDate, 'E')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.datepicker__quick-select-date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 .5rem;
|
||||
width: 100%;
|
||||
height: 2.25rem;
|
||||
color: var(--text);
|
||||
transition: all $transition;
|
||||
|
||||
&:first-child {
|
||||
border-radius: $radius $radius 0 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--grey-100);
|
||||
}
|
||||
|
||||
.text {
|
||||
width: 100%;
|
||||
font-size: .85rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-right: .25rem;
|
||||
|
||||
.weekday {
|
||||
color: var(--text-light);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.flatpickr-container {
|
||||
:deep(.flatpickr-calendar) {
|
||||
margin: 0 auto 8px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.input) {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
</style>
|
149
frontend/src/components/input/editor/CommandsList.vue
Normal file
@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div class="items">
|
||||
<template v-if="items.length">
|
||||
<button
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
class="item"
|
||||
:class="{ 'is-selected': index === selectedIndex }"
|
||||
@click="selectItem(index)"
|
||||
>
|
||||
<icon :icon="item.icon" />
|
||||
<div class="description">
|
||||
<p>{{ item.title }}</p>
|
||||
<p>{{ item.description }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="item"
|
||||
>
|
||||
No result
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
/* eslint-disable vue/component-api-style */
|
||||
export default {
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
|
||||
command: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
selectedIndex: 0,
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
items() {
|
||||
this.selectedIndex = 0
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onKeyDown({event}) {
|
||||
if (event.key === 'ArrowUp') {
|
||||
this.upHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
this.downHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
this.enterHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
|
||||
upHandler() {
|
||||
this.selectedIndex = ((this.selectedIndex + this.items.length) - 1) % this.items.length
|
||||
},
|
||||
|
||||
downHandler() {
|
||||
this.selectedIndex = (this.selectedIndex + 1) % this.items.length
|
||||
},
|
||||
|
||||
enterHandler() {
|
||||
this.selectItem(this.selectedIndex)
|
||||
},
|
||||
|
||||
selectItem(index) {
|
||||
const item = this.items[index]
|
||||
|
||||
if (item) {
|
||||
this.command(item)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.items {
|
||||
padding: 0.2rem;
|
||||
position: relative;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--white);
|
||||
color: var(--grey-900);
|
||||
overflow: hidden;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border-radius: $radius;
|
||||
border: 0;
|
||||
padding: 0.2rem 0.4rem;
|
||||
transition: background-color $transition;
|
||||
|
||||
&.is-selected, &:hover {
|
||||
background: var(--grey-100);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
> svg {
|
||||
box-sizing: border-box;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 1px solid var(--grey-300);
|
||||
padding: .5rem;
|
||||
margin-right: .5rem;
|
||||
border-radius: $radius;
|
||||
color: var(--grey-700);
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: .9rem;
|
||||
color: var(--grey-800);
|
||||
|
||||
p:last-child {
|
||||
font-size: .75rem;
|
||||
color: var(--grey-500);
|
||||
}
|
||||
}
|
||||
</style>
|
422
frontend/src/components/input/editor/EditorToolbar.vue
Normal file
@ -0,0 +1,422 @@
|
||||
<template>
|
||||
<div class="editor-toolbar">
|
||||
<div class="editor-toolbar__segment">
|
||||
<BaseButton
|
||||
v-tooltip="$t('input.editor.heading1')"
|
||||
class="editor-toolbar__button"
|
||||
:class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
|
||||
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-header']" />
|
||||
<span class="icon__lower-text">1</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-tooltip="$t('input.editor.heading2')"
|
||||
class="editor-toolbar__button"
|
||||
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
|
||||
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-header']" />
|
||||
<span class="icon__lower-text">2</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-tooltip="$t('input.editor.heading3')"
|
||||
class="editor-toolbar__button"
|
||||
:class="{ 'is-active': editor.isActive('heading', { level: 3 }) }"
|
||||
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-header']" />
|
||||
<span class="icon__lower-text">3</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar__segment">
|
||||
<BaseButton
|
||||
v-tooltip="$t('input.editor.bold')"
|
||||
class="editor-toolbar__button"
|
||||
:class="{ 'is-active': editor.isActive('bold') }"
|
||||
@click="editor.chain().focus().toggleBold().run()"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-bold']" />
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-tooltip="$t('input.editor.italic')"
|
||||
class="editor-toolbar__button"
|
||||
:class="{ 'is-active': editor.isActive('italic') }"
|
||||
@click="editor.chain().focus().toggleItalic().run()"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-italic']" />
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-tooltip="$t('input.editor.underline')"
|
||||
class="editor-toolbar__button"
|
||||
:class="{ 'is-active': editor.isActive('underline') }"
|
||||
@click="editor.chain().focus().toggleUnderline().run()"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-underline']" />
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-tooltip="$t('input.editor.strikethrough')"
|
||||
class="editor-toolbar__button"
|
||||
:class="{ 'is-active': editor.isActive('strike') }"
|
||||
@click="editor.chain().focus().toggleStrike().run()"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-strikethrough']" />
|
||||
</span>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar__segment">
|
||||
<BaseButton
|
||||
v-tooltip="$t('input.editor.code')"
|
||||
class="editor-toolbar__button"
|
||||
:class="{ 'is-active': editor.isActive('codeBlock') }"
|
||||
@click="editor.chain().focus().toggleCodeBlock().run()"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-code']" />
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-tooltip="$t('input.editor.quote')"
|
||||
class="editor-toolbar__button"
|
||||
:class="{ 'is-active': editor.isActive('blockquote') }"
|
||||
@click="editor.chain().focus().toggleBlockquote().run()"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-quote-right']" />
|
||||
</span>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar__segment">
|
||||
<BaseButton
|
||||
v-tooltip="$t('input.editor.bulletList')"
|
||||
class="editor-toolbar__button"
|
||||
:class="{ 'is-active': editor.isActive('bulletList') }"
|
||||
@click="editor.chain().focus().toggleBulletList().run()"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-list-ul']" />
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-tooltip="$t('input.editor.orderedList')"
|
||||
class="editor-toolbar__button"
|
||||
:class="{ 'is-active': editor.isActive('orderedList') }"
|
||||
@click="editor.chain().focus().toggleOrderedList().run()"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-list-ol']" />
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-tooltip="$t('input.editor.taskList')"
|
||||
class="editor-toolbar__button"
|
||||
:class="{ 'is-active': editor.isActive('taskList') }"
|
||||
@click="editor.chain().focus().toggleTaskList().run()"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon icon="fa-list-check" />
|
||||
</span>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar__segment">
|
||||
<BaseButton
|
||||
v-tooltip="$t('input.editor.image')"
|
||||
class="editor-toolbar__button"
|
||||
@click="openImagePicker"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon icon="fa-image" />
|
||||
</span>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar__segment">
|
||||
<BaseButton
|
||||
v-tooltip="$t('input.editor.link')"
|
||||
class="editor-toolbar__button"
|
||||
:class="{ 'is-active': editor.isActive('link') }"
|
||||
title="set link"
|
||||
@click="setLink"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-link']" />
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-tooltip="$t('input.editor.text')"
|
||||
class="editor-toolbar__button"
|
||||
:class="{ 'is-active': editor.isActive('paragraph') }"
|
||||
title="paragraph"
|
||||
@click="editor.chain().focus().setParagraph().run()"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-paragraph']" />
|
||||
</span>
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
v-tooltip="$t('input.editor.horizontalRule')"
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().setHorizontalRule().run()"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-ruler-horizontal']" />
|
||||
</span>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar__segment">
|
||||
<BaseButton
|
||||
v-tooltip="$t('input.editor.undo')"
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().undo().run()"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-undo']" />
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-tooltip="$t('input.editor.redo')"
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().redo().run()"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-redo']" />
|
||||
</span>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar__segment">
|
||||
<!-- table -->
|
||||
<BaseButton
|
||||
v-tooltip="$t('input.editor.table.title')"
|
||||
class="editor-toolbar__button"
|
||||
:class="{ 'is-active': editor.isActive('table') }"
|
||||
@click="toggleTableMode"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-table']" />
|
||||
</span>
|
||||
</BaseButton>
|
||||
<div
|
||||
v-if="tableMode"
|
||||
class="editor-toolbar__table-buttons"
|
||||
>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||
.run()
|
||||
"
|
||||
>
|
||||
{{ $t('input.editor.table.insert') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
:disabled="!editor.can().addColumnBefore"
|
||||
@click="editor.chain().focus().addColumnBefore().run()"
|
||||
>
|
||||
{{ $t('input.editor.table.addColumnBefore') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
:disabled="!editor.can().addColumnAfter"
|
||||
@click="editor.chain().focus().addColumnAfter().run()"
|
||||
>
|
||||
{{ $t('input.editor.table.addColumnAfter') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
:disabled="!editor.can().deleteColumn"
|
||||
@click="editor.chain().focus().deleteColumn().run()"
|
||||
>
|
||||
{{ $t('input.editor.table.deleteColumn') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
:disabled="!editor.can().addRowBefore"
|
||||
@click="editor.chain().focus().addRowBefore().run()"
|
||||
>
|
||||
{{ $t('input.editor.table.addRowBefore') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
:disabled="!editor.can().addRowAfter"
|
||||
@click="editor.chain().focus().addRowAfter().run()"
|
||||
>
|
||||
{{ $t('input.editor.table.addRowAfter') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
:disabled="!editor.can().deleteRow"
|
||||
@click="editor.chain().focus().deleteRow().run()"
|
||||
>
|
||||
{{ $t('input.editor.table.deleteRow') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
:disabled="!editor.can().deleteTable"
|
||||
@click="editor.chain().focus().deleteTable().run()"
|
||||
>
|
||||
{{ $t('input.editor.table.deleteTable') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
:disabled="!editor.can().mergeCells"
|
||||
@click="editor.chain().focus().mergeCells().run()"
|
||||
>
|
||||
{{ $t('input.editor.table.mergeCells') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
:disabled="!editor.can().splitCell"
|
||||
@click="editor.chain().focus().splitCell().run()"
|
||||
>
|
||||
{{ $t('input.editor.table.splitCell') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
:disabled="!editor.can().toggleHeaderColumn"
|
||||
@click="editor.chain().focus().toggleHeaderColumn().run()"
|
||||
>
|
||||
{{ $t('input.editor.table.toggleHeaderColumn') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
:disabled="!editor.can().toggleHeaderRow"
|
||||
@click="editor.chain().focus().toggleHeaderRow().run()"
|
||||
>
|
||||
{{ $t('input.editor.table.toggleHeaderRow') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
:disabled="!editor.can().toggleHeaderCell"
|
||||
@click="editor.chain().focus().toggleHeaderCell().run()"
|
||||
>
|
||||
{{ $t('input.editor.table.toggleHeaderCell') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
:disabled="!editor.can().mergeOrSplit"
|
||||
@click="editor.chain().focus().mergeOrSplit().run()"
|
||||
>
|
||||
{{ $t('input.editor.table.mergeOrSplit') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
:disabled="!editor.can().fixTables"
|
||||
@click="editor.chain().focus().fixTables().run()"
|
||||
>
|
||||
{{ $t('input.editor.table.fixTables') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
import {Editor} from '@tiptap/vue-3'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import {setLinkInEditor} from '@/components/input/editor/setLinkInEditor'
|
||||
|
||||
const {
|
||||
editor = null,
|
||||
} = defineProps<{
|
||||
editor: Editor,
|
||||
}>()
|
||||
|
||||
const tableMode = ref(false)
|
||||
|
||||
function toggleTableMode() {
|
||||
tableMode.value = !tableMode.value
|
||||
}
|
||||
|
||||
function openImagePicker() {
|
||||
document.getElementById('tiptap__image-upload').click()
|
||||
}
|
||||
|
||||
function setLink(event) {
|
||||
setLinkInEditor(event.target.getBoundingClientRect(), editor)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.editor-toolbar {
|
||||
background: var(--white);
|
||||
border: 1px solid var(--grey-200);
|
||||
user-select: none;
|
||||
padding: .5rem;
|
||||
border-radius: $radius;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
> * + * {
|
||||
border-left: 1px solid var(--grey-200);
|
||||
margin-left: 6px;
|
||||
padding-left: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-toolbar__button {
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: $radius;
|
||||
border: 1px solid transparent;
|
||||
color: var(--grey-700);
|
||||
transition: all $transition;
|
||||
background: transparent;
|
||||
margin-right: .25rem;
|
||||
|
||||
&:hover {
|
||||
background: var(--grey-100);
|
||||
border-color: var(--grey-200);
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: relative;
|
||||
|
||||
.icon__lower-text {
|
||||
font-size: .75rem;
|
||||
position: absolute;
|
||||
bottom: -3px;
|
||||
right: -2px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editor-toolbar__table-buttons {
|
||||
margin-top: .5rem;
|
||||
|
||||
> .editor-toolbar__button {
|
||||
margin-right: .5rem;
|
||||
margin-bottom: .5rem;
|
||||
padding: 0 .25rem;
|
||||
border: 1px solid var(--grey-400);
|
||||
font-size: .75rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
945
frontend/src/components/input/editor/TipTap.vue
Normal file
@ -0,0 +1,945 @@
|
||||
<template>
|
||||
<div
|
||||
ref="tiptapInstanceRef"
|
||||
class="tiptap"
|
||||
>
|
||||
<EditorToolbar
|
||||
v-if="editor && isEditing"
|
||||
:editor="editor"
|
||||
:upload-callback="uploadCallback"
|
||||
/>
|
||||
<BubbleMenu
|
||||
v-if="editor && isEditing"
|
||||
:editor="editor"
|
||||
class="editor-bubble__wrapper"
|
||||
>
|
||||
<BaseButton
|
||||
v-tooltip="$t('input.editor.bold')"
|
||||
class="editor-bubble__button"
|
||||
:class="{ 'is-active': editor.isActive('bold') }"
|
||||
@click="editor.chain().focus().toggleBold().run()"
|
||||
>
|
||||
<icon :icon="['fa', 'fa-bold']" />
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-tooltip="$t('input.editor.italic')"
|
||||
class="editor-bubble__button"
|
||||
:class="{ 'is-active': editor.isActive('italic') }"
|
||||
@click="editor.chain().focus().toggleItalic().run()"
|
||||
>
|
||||
<icon :icon="['fa', 'fa-italic']" />
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-tooltip="$t('input.editor.underline')"
|
||||
class="editor-bubble__button"
|
||||
:class="{ 'is-active': editor.isActive('underline') }"
|
||||
@click="editor.chain().focus().toggleUnderline().run()"
|
||||
>
|
||||
<icon :icon="['fa', 'fa-underline']" />
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-tooltip="$t('input.editor.strikethrough')"
|
||||
class="editor-bubble__button"
|
||||
:class="{ 'is-active': editor.isActive('strike') }"
|
||||
@click="editor.chain().focus().toggleStrike().run()"
|
||||
>
|
||||
<icon :icon="['fa', 'fa-strikethrough']" />
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-tooltip="$t('input.editor.code')"
|
||||
class="editor-bubble__button"
|
||||
:class="{ 'is-active': editor.isActive('code') }"
|
||||
@click="editor.chain().focus().toggleCode().run()"
|
||||
>
|
||||
<icon :icon="['fa', 'fa-code']" />
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-tooltip="$t('input.editor.link')"
|
||||
class="editor-bubble__button"
|
||||
:class="{ 'is-active': editor.isActive('link') }"
|
||||
@click="setLink"
|
||||
>
|
||||
<icon :icon="['fa', 'fa-link']" />
|
||||
</BaseButton>
|
||||
</BubbleMenu>
|
||||
|
||||
<EditorContent
|
||||
class="tiptap__editor"
|
||||
:class="{'tiptap__editor-is-edit-enabled': isEditing}"
|
||||
:editor="editor"
|
||||
@click="focusIfEditing()"
|
||||
/>
|
||||
|
||||
<input
|
||||
v-if="isEditing"
|
||||
id="tiptap__image-upload"
|
||||
ref="uploadInputRef"
|
||||
type="file"
|
||||
class="is-hidden"
|
||||
@change="addImage"
|
||||
>
|
||||
|
||||
<ul
|
||||
v-if="bottomActions.length === 0 && !isEditing && isEditEnabled"
|
||||
class="tiptap__editor-actions d-print-none"
|
||||
>
|
||||
<li>
|
||||
<BaseButton
|
||||
class="done-edit"
|
||||
@click="setEdit"
|
||||
>
|
||||
{{ $t('input.editor.edit') }}
|
||||
</BaseButton>
|
||||
</li>
|
||||
</ul>
|
||||
<ul
|
||||
v-if="bottomActions.length > 0"
|
||||
class="tiptap__editor-actions d-print-none"
|
||||
>
|
||||
<li v-if="isEditing && showSave">
|
||||
<BaseButton
|
||||
class="done-edit"
|
||||
@click="bubbleSave"
|
||||
>
|
||||
{{ $t('misc.save') }}
|
||||
</BaseButton>
|
||||
</li>
|
||||
<li v-if="!isEditing">
|
||||
<BaseButton
|
||||
class="done-edit"
|
||||
@click="setEdit"
|
||||
>
|
||||
{{ $t('input.editor.edit') }}
|
||||
</BaseButton>
|
||||
</li>
|
||||
<li
|
||||
v-for="(action, k) in bottomActions"
|
||||
:key="k"
|
||||
>
|
||||
<BaseButton @click="action.action">
|
||||
{{ action.title }}
|
||||
</BaseButton>
|
||||
</li>
|
||||
</ul>
|
||||
<XButton
|
||||
v-else-if="isEditing && showSave"
|
||||
v-cy="'saveEditor'"
|
||||
class="mt-4"
|
||||
variant="secondary"
|
||||
:shadow="false"
|
||||
:disabled="!contentHasChanged"
|
||||
@click="bubbleSave"
|
||||
>
|
||||
{{ $t('misc.save') }}
|
||||
</XButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, nextTick, onBeforeUnmount, onMounted, ref, watch} from 'vue'
|
||||
|
||||
import EditorToolbar from './EditorToolbar.vue'
|
||||
|
||||
import Link from '@tiptap/extension-link'
|
||||
|
||||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
|
||||
import Table from '@tiptap/extension-table'
|
||||
import TableCell from '@tiptap/extension-table-cell'
|
||||
import TableHeader from '@tiptap/extension-table-header'
|
||||
import TableRow from '@tiptap/extension-table-row'
|
||||
import Typography from '@tiptap/extension-typography'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import Underline from '@tiptap/extension-underline'
|
||||
|
||||
import TaskItem from '@tiptap/extension-task-item'
|
||||
import TaskList from '@tiptap/extension-task-list'
|
||||
|
||||
import {Blockquote} from '@tiptap/extension-blockquote'
|
||||
import {Bold} from '@tiptap/extension-bold'
|
||||
import {BulletList} from '@tiptap/extension-bullet-list'
|
||||
import {Code} from '@tiptap/extension-code'
|
||||
import {Document} from '@tiptap/extension-document'
|
||||
import {Dropcursor} from '@tiptap/extension-dropcursor'
|
||||
import {Gapcursor} from '@tiptap/extension-gapcursor'
|
||||
import {HardBreak} from '@tiptap/extension-hard-break'
|
||||
import {Heading} from '@tiptap/extension-heading'
|
||||
import {History} from '@tiptap/extension-history'
|
||||
import {HorizontalRule} from '@tiptap/extension-horizontal-rule'
|
||||
import {Italic} from '@tiptap/extension-italic'
|
||||
import {ListItem} from '@tiptap/extension-list-item'
|
||||
import {OrderedList} from '@tiptap/extension-ordered-list'
|
||||
import {Paragraph} from '@tiptap/extension-paragraph'
|
||||
import {Strike} from '@tiptap/extension-strike'
|
||||
import {Text} from '@tiptap/extension-text'
|
||||
import {BubbleMenu, EditorContent, useEditor} from '@tiptap/vue-3'
|
||||
import {Node} from '@tiptap/pm/model'
|
||||
|
||||
import Commands from './commands'
|
||||
import suggestionSetup from './suggestion'
|
||||
|
||||
import {lowlight} from 'lowlight'
|
||||
|
||||
import type {BottomAction, UploadCallback} from './types'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import type {IAttachment} from '@/modelTypes/IAttachment'
|
||||
import AttachmentModel from '@/models/attachment'
|
||||
import AttachmentService from '@/services/attachment'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import XButton from '@/components/input/button.vue'
|
||||
import {Placeholder} from '@tiptap/extension-placeholder'
|
||||
import {eventToHotkeyString} from '@github/hotkey'
|
||||
import {mergeAttributes} from '@tiptap/core'
|
||||
import {isEditorContentEmpty} from '@/helpers/editorContentEmpty'
|
||||
import inputPrompt from '@/helpers/inputPrompt'
|
||||
import {setLinkInEditor} from '@/components/input/editor/setLinkInEditor'
|
||||
|
||||
const {
|
||||
modelValue,
|
||||
uploadCallback,
|
||||
isEditEnabled = true,
|
||||
bottomActions = [],
|
||||
showSave = false,
|
||||
placeholder = '',
|
||||
editShortcut = '',
|
||||
} = defineProps<{
|
||||
modelValue: string,
|
||||
uploadCallback?: UploadCallback,
|
||||
isEditEnabled?: boolean,
|
||||
bottomActions?: BottomAction[],
|
||||
showSave?: boolean,
|
||||
placeholder?: string,
|
||||
editShortcut?: string,
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'save'])
|
||||
|
||||
const tiptapInstanceRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const {t} = useI18n()
|
||||
|
||||
const CustomTableCell = TableCell.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
// extend the existing attributes …
|
||||
...this.parent?.(),
|
||||
|
||||
// and add a new one …
|
||||
backgroundColor: {
|
||||
default: null,
|
||||
parseHTML: (element: HTMLElement) => element.getAttribute('data-background-color'),
|
||||
renderHTML: (attributes) => {
|
||||
return {
|
||||
'data-background-color': attributes.backgroundColor,
|
||||
style: `background-color: ${attributes.backgroundColor}`,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
type CacheKey = `${ITask['id']}-${IAttachment['id']}`
|
||||
const loadedAttachments = ref<{
|
||||
[key: CacheKey]: string
|
||||
}>({})
|
||||
|
||||
const CustomImage = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
alt: {
|
||||
default: null,
|
||||
},
|
||||
title: {
|
||||
default: null,
|
||||
},
|
||||
id: {
|
||||
default: null,
|
||||
},
|
||||
'data-src': {
|
||||
default: null,
|
||||
},
|
||||
}
|
||||
},
|
||||
renderHTML({HTMLAttributes}) {
|
||||
if (HTMLAttributes.src?.startsWith(window.API_URL) || HTMLAttributes['data-src']?.startsWith(window.API_URL)) {
|
||||
const imageUrl = HTMLAttributes['data-src'] ?? HTMLAttributes.src
|
||||
|
||||
// The url is something like /tasks/<id>/attachments/<id>
|
||||
const parts = imageUrl.slice(window.API_URL.length + 1).split('/')
|
||||
const taskId = Number(parts[1])
|
||||
const attachmentId = Number(parts[3])
|
||||
const cacheKey: CacheKey = `${taskId}-${attachmentId}`
|
||||
const id = 'tiptap-image-' + cacheKey
|
||||
|
||||
nextTick(async () => {
|
||||
|
||||
const img = document.getElementById(id)
|
||||
|
||||
if (!img) return
|
||||
|
||||
if (typeof loadedAttachments.value[cacheKey] === 'undefined') {
|
||||
|
||||
const attachment = new AttachmentModel({taskId: taskId, id: attachmentId})
|
||||
|
||||
const attachmentService = new AttachmentService()
|
||||
loadedAttachments.value[cacheKey] = await attachmentService.getBlobUrl(attachment)
|
||||
}
|
||||
|
||||
img.src = loadedAttachments.value[cacheKey]
|
||||
})
|
||||
|
||||
return ['img', mergeAttributes(this.options.HTMLAttributes, {
|
||||
'data-src': imageUrl,
|
||||
src: '#',
|
||||
alt: HTMLAttributes.alt,
|
||||
title: HTMLAttributes.title,
|
||||
id,
|
||||
})]
|
||||
}
|
||||
|
||||
return ['img', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]
|
||||
},
|
||||
})
|
||||
|
||||
type Mode = 'edit' | 'preview'
|
||||
|
||||
const internalMode = ref<Mode>('preview')
|
||||
const isEditing = computed(() => internalMode.value === 'edit' && isEditEnabled)
|
||||
const contentHasChanged = ref<boolean>(false)
|
||||
|
||||
watch(
|
||||
() => internalMode.value,
|
||||
mode => {
|
||||
if (mode === 'preview') {
|
||||
contentHasChanged.value = false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const editor = useEditor({
|
||||
// eslint-disable-next-line vue/no-ref-object-destructure
|
||||
editable: isEditing.value,
|
||||
extensions: [
|
||||
// Starterkit:
|
||||
Blockquote,
|
||||
Bold,
|
||||
BulletList,
|
||||
Code,
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight,
|
||||
}),
|
||||
Document,
|
||||
Dropcursor,
|
||||
Gapcursor,
|
||||
HardBreak.extend({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
'Mod-Enter': () => {
|
||||
if (contentHasChanged.value) {
|
||||
bubbleSave()
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
Heading,
|
||||
History,
|
||||
HorizontalRule,
|
||||
Italic,
|
||||
ListItem,
|
||||
OrderedList,
|
||||
Paragraph,
|
||||
Strike,
|
||||
Text,
|
||||
|
||||
Placeholder.configure({
|
||||
placeholder: ({editor}) => {
|
||||
if (!isEditing.value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (editor.getText() !== '' && !editor.isFocused) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return placeholder !== ''
|
||||
? placeholder
|
||||
: t('input.editor.placeholder')
|
||||
},
|
||||
}),
|
||||
Typography,
|
||||
Underline,
|
||||
Link.configure({
|
||||
openOnClick: true,
|
||||
validate: (href: string) => /^https?:\/\//.test(href),
|
||||
}),
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
// Custom TableCell with backgroundColor attribute
|
||||
CustomTableCell,
|
||||
|
||||
CustomImage,
|
||||
|
||||
TaskList,
|
||||
TaskItem.configure({
|
||||
nested: true,
|
||||
onReadOnlyChecked: (node: Node, checked: boolean): boolean => {
|
||||
if (!isEditEnabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
// The following is a workaround for this bug:
|
||||
// https://github.com/ueberdosis/tiptap/issues/4521
|
||||
// https://github.com/ueberdosis/tiptap/issues/3676
|
||||
|
||||
editor.value!.state.doc.descendants((subnode, pos) => {
|
||||
if (node.eq(subnode)) {
|
||||
const {tr} = editor.value!.state
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
checked,
|
||||
})
|
||||
editor.value!.view.dispatch(tr)
|
||||
bubbleSave()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
return true
|
||||
},
|
||||
}),
|
||||
|
||||
Commands.configure({
|
||||
suggestion: suggestionSetup(t),
|
||||
}),
|
||||
BubbleMenu,
|
||||
],
|
||||
onUpdate: () => {
|
||||
bubbleNow()
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => isEditing.value,
|
||||
() => {
|
||||
editor.value?.setEditable(isEditing.value)
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => modelValue,
|
||||
value => {
|
||||
if (!editor?.value) return
|
||||
|
||||
if (editor.value.getHTML() === value) {
|
||||
return
|
||||
}
|
||||
|
||||
setModeAndValue(value)
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
function bubbleNow() {
|
||||
if (editor.value?.getHTML() === modelValue) {
|
||||
return
|
||||
}
|
||||
|
||||
contentHasChanged.value = true
|
||||
emit('update:modelValue', editor.value?.getHTML())
|
||||
}
|
||||
|
||||
function bubbleSave() {
|
||||
bubbleNow()
|
||||
emit('save', editor.value?.getHTML())
|
||||
if (isEditing.value) {
|
||||
internalMode.value = 'preview'
|
||||
}
|
||||
}
|
||||
|
||||
function setEdit(focus: boolean = true) {
|
||||
internalMode.value = 'edit'
|
||||
if (focus) {
|
||||
editor.value?.commands.focus()
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => editor.value?.destroy())
|
||||
|
||||
const uploadInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function uploadAndInsertFiles(files: File[] | FileList) {
|
||||
uploadCallback(files).then(urls => {
|
||||
urls?.forEach(url => {
|
||||
editor.value
|
||||
?.chain()
|
||||
.focus()
|
||||
.setImage({src: url})
|
||||
.run()
|
||||
})
|
||||
bubbleSave()
|
||||
})
|
||||
}
|
||||
|
||||
async function addImage(event) {
|
||||
|
||||
if (typeof uploadCallback !== 'undefined') {
|
||||
const files = uploadInputRef.value?.files
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
uploadAndInsertFiles(files)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const url = await inputPrompt(event.target.getBoundingClientRect())
|
||||
|
||||
if (url) {
|
||||
editor.value?.chain().focus().setImage({src: url}).run()
|
||||
bubbleSave()
|
||||
}
|
||||
}
|
||||
|
||||
function setLink(event) {
|
||||
setLinkInEditor(event.target.getBoundingClientRect(), editor.value)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (editShortcut !== '') {
|
||||
document.addEventListener('keydown', setFocusToEditor)
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
const input = tiptapInstanceRef.value?.querySelectorAll('.tiptap__editor')[0]?.children[0]
|
||||
input?.addEventListener('paste', handleImagePaste)
|
||||
|
||||
setModeAndValue(modelValue)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
nextTick(() => {
|
||||
const input = tiptapInstanceRef.value?.querySelectorAll('.tiptap__editor')[0]?.children[0]
|
||||
input?.removeEventListener('paste', handleImagePaste)
|
||||
})
|
||||
if (editShortcut !== '') {
|
||||
document.removeEventListener('keydown', setFocusToEditor)
|
||||
}
|
||||
})
|
||||
|
||||
function setModeAndValue(value: string) {
|
||||
internalMode.value = isEditorContentEmpty(value) ? 'edit' : 'preview'
|
||||
editor.value?.commands.setContent(value, false)
|
||||
}
|
||||
|
||||
function handleImagePaste(event) {
|
||||
if (event?.clipboardData?.items?.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const image = event.clipboardData.items[0]
|
||||
if (image.kind === 'file' && image.type.startsWith('image/')) {
|
||||
uploadAndInsertFiles([image.getAsFile()])
|
||||
}
|
||||
}
|
||||
|
||||
// See https://github.com/github/hotkey/discussions/85#discussioncomment-5214660
|
||||
function setFocusToEditor(event) {
|
||||
const hotkeyString = eventToHotkeyString(event)
|
||||
if (!hotkeyString) return
|
||||
if (hotkeyString !== editShortcut ||
|
||||
event.target.tagName.toLowerCase() === 'input' ||
|
||||
event.target.tagName.toLowerCase() === 'textarea' ||
|
||||
event.target.contentEditable === 'true') {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
if (!isEditing.value && isEditEnabled) {
|
||||
internalMode.value = 'edit'
|
||||
}
|
||||
|
||||
editor.value?.commands.focus()
|
||||
}
|
||||
|
||||
function focusIfEditing() {
|
||||
if (isEditing.value) {
|
||||
editor.value?.commands.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function clickTasklistCheckbox(event) {
|
||||
event.stopImmediatePropagation()
|
||||
|
||||
if (event.target.localName !== 'p') {
|
||||
return
|
||||
}
|
||||
|
||||
event.target.parentNode.parentNode.firstChild.click()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => isEditing.value,
|
||||
editing => {
|
||||
nextTick(() => {
|
||||
const checkboxes = tiptapInstanceRef.value?.querySelectorAll('[data-checked]')
|
||||
if (typeof checkboxes === 'undefined' || checkboxes.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
checkboxes.forEach(check => {
|
||||
if (check.children.length < 2) {
|
||||
return
|
||||
}
|
||||
|
||||
// We assume the first child contains the label element with the checkbox and the second child the actual label
|
||||
// When the actual label is clicked, we forward that click to the checkbox.
|
||||
check.children[1].removeEventListener('click', clickTasklistCheckbox)
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
checkboxes.forEach(check => {
|
||||
if (check.children.length < 2) {
|
||||
return
|
||||
}
|
||||
|
||||
// We assume the first child contains the label element with the checkbox and the second child the actual label
|
||||
// When the actual label is clicked, we forward that click to the checkbox.
|
||||
check.children[1].addEventListener('click', clickTasklistCheckbox)
|
||||
})
|
||||
})
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.tiptap__editor {
|
||||
&.tiptap__editor-is-edit-enabled {
|
||||
min-height: 10rem;
|
||||
|
||||
.ProseMirror {
|
||||
padding: .5rem;
|
||||
}
|
||||
|
||||
&:focus-within, &:focus {
|
||||
box-shadow: 0 0 0 2px hsla(var(--primary-hsl), 0.5);
|
||||
}
|
||||
|
||||
ul[data-type='taskList'] li > div {
|
||||
cursor: text;
|
||||
}
|
||||
}
|
||||
|
||||
transition: box-shadow $transition;
|
||||
border-radius: $radius;
|
||||
}
|
||||
|
||||
.tiptap p::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--grey-400);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
float: left;
|
||||
}
|
||||
|
||||
// Basic editor styles
|
||||
.ProseMirror {
|
||||
padding: .5rem .5rem .5rem 0;
|
||||
|
||||
&:focus-within, &:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
> * + * {
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: var(--grey-200);
|
||||
color: var(--grey-700);
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--grey-200);
|
||||
color: var(--grey-700);
|
||||
font-family: 'JetBrainsMono', monospace;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: $radius;
|
||||
|
||||
code {
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
background: none;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: var(--grey-500);
|
||||
}
|
||||
|
||||
.hljs-variable,
|
||||
.hljs-template-variable,
|
||||
.hljs-attribute,
|
||||
.hljs-tag,
|
||||
.hljs-name,
|
||||
.hljs-regexp,
|
||||
.hljs-link,
|
||||
.hljs-name,
|
||||
.hljs-selector-id,
|
||||
.hljs-selector-class {
|
||||
color: var(--code-variable);
|
||||
}
|
||||
|
||||
.hljs-number,
|
||||
.hljs-meta,
|
||||
.hljs-built_in,
|
||||
.hljs-builtin-name,
|
||||
.hljs-literal,
|
||||
.hljs-type,
|
||||
.hljs-params {
|
||||
color: var(--code-literal);
|
||||
}
|
||||
|
||||
.hljs-string,
|
||||
.hljs-symbol,
|
||||
.hljs-bullet {
|
||||
color: var(--code-symbol);
|
||||
}
|
||||
|
||||
.hljs-title,
|
||||
.hljs-section {
|
||||
color: var(--code-section);
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag {
|
||||
color: var(--code-keyword);
|
||||
}
|
||||
|
||||
.hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
|
||||
&.ProseMirror-selectednode {
|
||||
outline: 3px solid var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding-left: 1rem;
|
||||
border-left: 2px solid rgba(#0d0d0d, 0.1);
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 2px solid rgba(#0d0d0d, 0.1);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
/* Table-specific styling */
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
|
||||
td,
|
||||
th {
|
||||
min-width: 1em;
|
||||
border: 2px solid #ced4da;
|
||||
padding: 3px 5px;
|
||||
vertical-align: top;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
|
||||
> * {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
background-color: #f1f3f5;
|
||||
}
|
||||
|
||||
.selectedCell:after {
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
content: '';
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(200, 200, 255, 0.4);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.column-resize-handle {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
top: 0;
|
||||
bottom: -2px;
|
||||
width: 4px;
|
||||
background-color: #adf;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Lists
|
||||
ul {
|
||||
margin-left: .5rem;
|
||||
margin-top: 0 !important;
|
||||
|
||||
li {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.resize-cursor {
|
||||
cursor: ew-resize;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
// tasklist
|
||||
ul[data-type='taskList'] {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin-left: 0;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
|
||||
> label {
|
||||
flex: 0 0 auto;
|
||||
margin-right: 0.5rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
> div {
|
||||
flex: 1 1 auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-bubble__wrapper {
|
||||
background: var(--white);
|
||||
border-radius: $radius;
|
||||
border: 1px solid var(--grey-200);
|
||||
box-shadow: var(--shadow-md);
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-bubble__button {
|
||||
color: var(--grey-700);
|
||||
transition: all $transition;
|
||||
background: transparent;
|
||||
|
||||
svg {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
padding: .5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--grey-200);
|
||||
}
|
||||
}
|
||||
|
||||
ul.tiptap__editor-actions {
|
||||
font-size: .8rem;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
|
||||
&::after {
|
||||
content: '·';
|
||||
padding: 0 .25rem;
|
||||
}
|
||||
|
||||
&:last-child:after {
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
&, a {
|
||||
color: var(--grey-500);
|
||||
|
||||
&.done-edit {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
</style>
|
28
frontend/src/components/input/editor/commands.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import {Extension} from '@tiptap/core'
|
||||
import Suggestion from '@tiptap/suggestion'
|
||||
|
||||
// Copied and adjusted from https://github.com/ueberdosis/tiptap/tree/252acb32d27a0f9af14813eeed83d8a50059a43a/demos/src/Experiments/Commands/Vue
|
||||
|
||||
export default Extension.create({
|
||||
name: 'slash-menu-commands',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
suggestion: {
|
||||
char: '/',
|
||||
command: ({editor, range, props}) => {
|
||||
props.command({editor, range})
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
26
frontend/src/components/input/editor/setLinkInEditor.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import inputPrompt from '@/helpers/inputPrompt'
|
||||
|
||||
export async function setLinkInEditor(pos, editor) {
|
||||
const previousUrl = editor?.getAttributes('link').href || ''
|
||||
const url = await inputPrompt(pos, previousUrl)
|
||||
|
||||
// empty
|
||||
if (url === '') {
|
||||
editor
|
||||
?.chain()
|
||||
.focus()
|
||||
.extendMarkRange('link')
|
||||
.unsetLink()
|
||||
.run()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// update link
|
||||
editor
|
||||
?.chain()
|
||||
.focus()
|
||||
.extendMarkRange('link')
|
||||
.setLink({href: url, target: '_blank'})
|
||||
.run()
|
||||
}
|
214
frontend/src/components/input/editor/suggestion.ts
Normal file
@ -0,0 +1,214 @@
|
||||
import {VueRenderer} from '@tiptap/vue-3'
|
||||
import tippy from 'tippy.js'
|
||||
|
||||
import CommandsList from './CommandsList.vue'
|
||||
|
||||
export default function suggestionSetup(t) {
|
||||
return {
|
||||
items: ({query}: { query: string }) => {
|
||||
return [
|
||||
{
|
||||
title: t('input.editor.text'),
|
||||
description: t('input.editor.textTooltip'),
|
||||
icon: 'fa-font',
|
||||
command: ({editor, range}) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setNode('paragraph', {level: 1})
|
||||
.run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('input.editor.heading1'),
|
||||
description: t('input.editor.heading1Tooltip'),
|
||||
icon: 'fa-header',
|
||||
command: ({editor, range}) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setNode('heading', {level: 1})
|
||||
.run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('input.editor.heading2'),
|
||||
description: t('input.editor.heading2Tooltip'),
|
||||
icon: 'fa-header',
|
||||
command: ({editor, range}) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setNode('heading', {level: 2})
|
||||
.run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('input.editor.heading3'),
|
||||
description: t('input.editor.heading3Tooltip'),
|
||||
icon: 'fa-header',
|
||||
command: ({editor, range}) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setNode('heading', {level: 2})
|
||||
.run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('input.editor.bulletList'),
|
||||
description: t('input.editor.bulletListTooltip'),
|
||||
icon: 'fa-list-ul',
|
||||
command: ({editor, range}) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.toggleBulletList()
|
||||
.run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('input.editor.orderedList'),
|
||||
description: t('input.editor.orderedListTooltip'),
|
||||
icon: 'fa-list-ol',
|
||||
command: ({editor, range}) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.toggleOrderedList()
|
||||
.run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('input.editor.taskList'),
|
||||
description: t('input.editor.taskListTooltip'),
|
||||
icon: 'fa-list-check',
|
||||
command: ({editor, range}) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.toggleTaskList()
|
||||
.run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('input.editor.quote'),
|
||||
description: t('input.editor.quoteTooltip'),
|
||||
icon: 'fa-quote-right',
|
||||
command: ({editor, range}) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.toggleBlockquote()
|
||||
.run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('input.editor.code'),
|
||||
description: t('input.editor.codeTooltip'),
|
||||
icon: 'fa-code',
|
||||
command: ({editor, range}) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.toggleCodeBlock()
|
||||
.run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('input.editor.image'),
|
||||
description: t('input.editor.imageTooltip'),
|
||||
icon: 'fa-image',
|
||||
command: ({editor, range}) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.run()
|
||||
document.getElementById('tiptap__image-upload').click()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('input.editor.horizontalRule'),
|
||||
description: t('input.editor.horizontalRuleTooltip'),
|
||||
icon: 'fa-ruler-horizontal',
|
||||
command: ({editor, range}) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setHorizontalRule()
|
||||
.run()
|
||||
},
|
||||
},
|
||||
].filter(item => item.title.toLowerCase().startsWith(query.toLowerCase()))
|
||||
},
|
||||
|
||||
render: () => {
|
||||
let component: VueRenderer
|
||||
let popup
|
||||
|
||||
return {
|
||||
onStart: props => {
|
||||
component = new VueRenderer(CommandsList, {
|
||||
// using vue 2:
|
||||
// parent: this,
|
||||
// propsData: props,
|
||||
props,
|
||||
editor: props.editor,
|
||||
})
|
||||
|
||||
if (!props.clientRect) {
|
||||
return
|
||||
}
|
||||
|
||||
popup = tippy('body', {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'bottom-start',
|
||||
})
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
component.updateProps(props)
|
||||
|
||||
if (!props.clientRect) {
|
||||
return
|
||||
}
|
||||
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
})
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup[0].hide()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return component.ref?.onKeyDown(props)
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup[0].destroy()
|
||||
component.destroy()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
6
frontend/src/components/input/editor/types.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export type UploadCallback = (files: File[] | FileList) => Promise<string[]>
|
||||
|
||||
export interface BottomAction {
|
||||
title: string
|
||||
action: () => void,
|
||||
}
|
85
frontend/src/components/input/fancycheckbox.story.vue
Normal file
@ -0,0 +1,85 @@
|
||||
<script lang="ts" setup>
|
||||
import {ref} from 'vue'
|
||||
import {logEvent} from 'histoire/client'
|
||||
import FancyCheckbox from './fancycheckbox.vue'
|
||||
|
||||
const isDisabled = ref<boolean | undefined>()
|
||||
|
||||
const isChecked = ref(false)
|
||||
|
||||
const isCheckedInitiallyEnabled = ref(true)
|
||||
|
||||
const isCheckedDisabled = ref(false)
|
||||
|
||||
const withoutInitialState = ref<boolean | undefined>()
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<Story :layout="{ type: 'grid', width: '200px' }">
|
||||
<Variant title="Default">
|
||||
<FancyCheckbox
|
||||
v-model="isChecked"
|
||||
:disabled="isDisabled"
|
||||
>
|
||||
This is probably not important
|
||||
</FancyCheckbox>
|
||||
|
||||
Visualisation
|
||||
<input
|
||||
v-model="isChecked"
|
||||
type="checkbox"
|
||||
>
|
||||
{{ isChecked }}
|
||||
</Variant>
|
||||
<Variant title="Enabled Initially">
|
||||
<FancyCheckbox
|
||||
v-model="isCheckedInitiallyEnabled"
|
||||
:disabled="isDisabled"
|
||||
>
|
||||
We want you to use this option
|
||||
</FancyCheckbox>
|
||||
|
||||
Visualisation
|
||||
<input
|
||||
v-model="isCheckedInitiallyEnabled"
|
||||
type="checkbox"
|
||||
>
|
||||
{{ isCheckedInitiallyEnabled }}
|
||||
</Variant>
|
||||
<Variant title="Disabled">
|
||||
<FancyCheckbox
|
||||
disabled
|
||||
:model-value="isCheckedDisabled"
|
||||
@update:modelValue="logEvent('Setting disabled: This should never happen', $event)"
|
||||
>
|
||||
You can't change this
|
||||
</FancyCheckbox>
|
||||
|
||||
Visualisation
|
||||
<input
|
||||
v-model="isCheckedDisabled"
|
||||
type="checkbox"
|
||||
disabled
|
||||
>
|
||||
{{ isCheckedDisabled }}
|
||||
</Variant>
|
||||
|
||||
<Variant title="Undefined initial State">
|
||||
<FancyCheckbox
|
||||
v-model="withoutInitialState"
|
||||
:disabled="isDisabled"
|
||||
>
|
||||
Not sure what the value should be
|
||||
</FancyCheckbox>
|
||||
|
||||
Visualisation
|
||||
<input
|
||||
v-model="withoutInitialState"
|
||||
type="checkbox"
|
||||
disabled
|
||||
>
|
||||
{{ withoutInitialState }}
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
103
frontend/src/components/input/fancycheckbox.vue
Normal file
@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<BaseCheckbox
|
||||
class="fancycheckbox"
|
||||
:class="{
|
||||
'is-disabled': disabled,
|
||||
'is-block': isBlock,
|
||||
}"
|
||||
:disabled="disabled"
|
||||
:model-value="modelValue"
|
||||
@update:modelValue="value => emit('update:modelValue', value)"
|
||||
>
|
||||
<CheckboxIcon class="fancycheckbox__icon" />
|
||||
<span
|
||||
v-if="$slots.default"
|
||||
class="fancycheckbox__content"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</BaseCheckbox>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import CheckboxIcon from '@/assets/checkbox.svg?component'
|
||||
|
||||
import BaseCheckbox from '@/components/base/BaseCheckbox.vue'
|
||||
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
},
|
||||
isBlock: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fancycheckbox {
|
||||
display: inline-block;
|
||||
padding-right: 5px;
|
||||
padding-top: 3px;
|
||||
|
||||
&.is-block {
|
||||
display: block;
|
||||
margin: .5rem .2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.fancycheckbox__content {
|
||||
font-size: 0.8rem;
|
||||
vertical-align: top;
|
||||
padding-left: .5rem;
|
||||
}
|
||||
|
||||
.fancycheckbox__icon:deep() {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
stroke: var(--stroke-color, #c8ccd4);
|
||||
transform: translate3d(0, 0, 0);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
path,
|
||||
polyline {
|
||||
transition: all 0.2s linear, color 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.fancycheckbox:not(:has(input:disabled)):hover .fancycheckbox__icon,
|
||||
.fancycheckbox:has(input:checked) .fancycheckbox__icon {
|
||||
--stroke-color: var(--primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
// Since css-has-pseudo doesn't work with deep classes,
|
||||
// the following rules can't be scoped
|
||||
|
||||
.fancycheckbox:has(:not(input:checked)) .fancycheckbox__icon {
|
||||
path {
|
||||
transition-delay: 0.05s;
|
||||
}
|
||||
}
|
||||
|
||||
.fancycheckbox:has(input:checked) .fancycheckbox__icon {
|
||||
path {
|
||||
stroke-dashoffset: 60;
|
||||
}
|
||||
|
||||
polyline {
|
||||
stroke-dashoffset: 42;
|
||||
transition-delay: 0.15s;
|
||||
}
|
||||
}
|
||||
</style>
|
635
frontend/src/components/input/multiselect.vue
Normal file
@ -0,0 +1,635 @@
|
||||
<template>
|
||||
<div
|
||||
ref="multiselectRoot"
|
||||
class="multiselect"
|
||||
:class="{'has-search-results': searchResultsVisible}"
|
||||
tabindex="-1"
|
||||
@focus="focus"
|
||||
>
|
||||
<div
|
||||
class="control"
|
||||
:class="{'is-loading': loading || localLoading}"
|
||||
>
|
||||
<div
|
||||
class="input-wrapper input"
|
||||
:class="{'has-multiple': hasMultiple, 'has-removal-button': removalAvailable}"
|
||||
>
|
||||
<slot
|
||||
v-if="Array.isArray(internalValue)"
|
||||
name="items"
|
||||
:items="internalValue"
|
||||
:remove="remove"
|
||||
>
|
||||
<template v-for="(item, key) in internalValue">
|
||||
<slot
|
||||
name="tag"
|
||||
:item="item"
|
||||
>
|
||||
<span
|
||||
:key="`item${key}`"
|
||||
class="tag ml-2 mt-2"
|
||||
>
|
||||
{{ label !== '' ? item[label] : item }}
|
||||
<BaseButton
|
||||
class="delete is-small"
|
||||
@click="() => remove(item)"
|
||||
/>
|
||||
</span>
|
||||
</slot>
|
||||
</template>
|
||||
</slot>
|
||||
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="query"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="placeholder"
|
||||
:autocomplete="autocompleteEnabled ? undefined : 'off'"
|
||||
:spellcheck="autocompleteEnabled ? undefined : 'false'"
|
||||
@keyup="search"
|
||||
@keyup.enter.exact.prevent="() => createOrSelectOnEnter()"
|
||||
@keydown.down.exact.prevent="() => preSelect(0)"
|
||||
@focus="handleFocus"
|
||||
>
|
||||
<BaseButton
|
||||
v-if="removalAvailable"
|
||||
class="removal-button"
|
||||
@click="resetSelectedValue"
|
||||
>
|
||||
<icon icon="times" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CustomTransition name="fade">
|
||||
<div
|
||||
v-if="searchResultsVisible"
|
||||
class="search-results"
|
||||
:class="{'search-results-inline': inline}"
|
||||
>
|
||||
<BaseButton
|
||||
v-for="(data, index) in filteredSearchResults"
|
||||
:key="index"
|
||||
:ref="(el) => setResult(el, index)"
|
||||
class="search-result-button is-fullwidth"
|
||||
@keydown.up.prevent="() => preSelect(index - 1)"
|
||||
@keydown.down.prevent="() => preSelect(index + 1)"
|
||||
@click.prevent.stop="() => select(data)"
|
||||
>
|
||||
<span>
|
||||
<slot
|
||||
name="searchResult"
|
||||
:option="data"
|
||||
>
|
||||
<span class="search-result">{{ label !== '' ? data[label] : data }}</span>
|
||||
</slot>
|
||||
</span>
|
||||
<span class="hint-text">
|
||||
{{ selectPlaceholder }}
|
||||
</span>
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
v-if="creatableAvailable"
|
||||
:ref="(el) => setResult(el, filteredSearchResults.length)"
|
||||
class="search-result-button is-fullwidth"
|
||||
@keydown.up.prevent="() => preSelect(filteredSearchResults.length - 1)"
|
||||
@keydown.down.prevent="() => preSelect(filteredSearchResults.length + 1)"
|
||||
@keyup.enter.prevent="create"
|
||||
@click.prevent.stop="create"
|
||||
>
|
||||
<span>
|
||||
<slot
|
||||
name="searchResult"
|
||||
:option="query"
|
||||
>
|
||||
<span class="search-result">
|
||||
{{ query }}
|
||||
</span>
|
||||
</slot>
|
||||
</span>
|
||||
<span class="hint-text">
|
||||
{{ createPlaceholder }}
|
||||
</span>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</CustomTransition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
computed, onBeforeUnmount, onMounted, ref, toRefs, watch, type ComponentPublicInstance, type PropType,
|
||||
} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* When true, shows a loading spinner
|
||||
*/
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/**
|
||||
* The placeholder of the search input
|
||||
*/
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
/**
|
||||
* The search results where the @search listener needs to put the results into
|
||||
*/
|
||||
searchResults: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type: Array as PropType<{ [id: string]: any }>,
|
||||
default: () => [],
|
||||
},
|
||||
/**
|
||||
* The name of the property of the searched object to show the user.
|
||||
* If empty the component will show all raw data of an entry.
|
||||
*/
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
/**
|
||||
* The object with the value, updated every time an entry is selected.
|
||||
*/
|
||||
modelValue: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type: [Object] as PropType<{ [key: string]: any }>,
|
||||
default: null,
|
||||
},
|
||||
/**
|
||||
* If true, will provide an "add this as a new value" entry which fires an @create event when clicking on it.
|
||||
*/
|
||||
creatable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/**
|
||||
* The text shown next to the new value option.
|
||||
*/
|
||||
createPlaceholder: {
|
||||
type: String,
|
||||
default() {
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
return t('input.multiselect.createPlaceholder')
|
||||
},
|
||||
},
|
||||
/**
|
||||
* The text shown next to an option.
|
||||
*/
|
||||
selectPlaceholder: {
|
||||
type: String,
|
||||
default() {
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
return t('input.multiselect.selectPlaceholder')
|
||||
},
|
||||
},
|
||||
/**
|
||||
* If true, allows for selecting multiple items. v-model will be an array with all selected values in that case.
|
||||
*/
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/**
|
||||
* If true, displays the search results inline instead of using a dropdown.
|
||||
*/
|
||||
inline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/**
|
||||
* If true, shows search results when no query is specified.
|
||||
*/
|
||||
showEmpty: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
/**
|
||||
* The delay in ms after which the search event will be fired. Used to avoid hitting the network on every keystroke.
|
||||
*/
|
||||
searchDelay: {
|
||||
type: Number,
|
||||
default: 200,
|
||||
},
|
||||
closeAfterSelect: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
/**
|
||||
* If false, the search input will get the autocomplete="off" attributes attached to it.
|
||||
*/
|
||||
autocompleteEnabled: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: null): void
|
||||
/**
|
||||
* Triggered every time the search query input changes
|
||||
*/
|
||||
(e: 'search', query: string): void
|
||||
/**
|
||||
* Triggered every time an option from the search results is selected. Also triggers a change in v-model.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(e: 'select', value: { [key: string]: any }): void
|
||||
/**
|
||||
* If nothing or no exact match was found and `creatable` is true, this event is triggered with the current value of the search query.
|
||||
*/
|
||||
(e: 'create', query: string): void
|
||||
/**
|
||||
* If `multiple` is enabled, this will be fired every time an item is removed from the array of selected items.
|
||||
*/
|
||||
(e: 'remove', value: null): void
|
||||
}>()
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function elementInResults(elem: string | any, label: string, query: string): boolean {
|
||||
// Don't make create available if we have an exact match in our search results.
|
||||
if (label !== '') {
|
||||
return elem[label] === query
|
||||
}
|
||||
|
||||
return elem === query
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const query = ref<string | { [key: string]: any }>('')
|
||||
const searchTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
const localLoading = ref(false)
|
||||
const showSearchResults = ref(false)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const internalValue = ref<string | { [key: string]: any } | any[] | null>(null)
|
||||
|
||||
onMounted(() => document.addEventListener('click', hideSearchResultsHandler))
|
||||
onBeforeUnmount(() => document.removeEventListener('click', hideSearchResultsHandler))
|
||||
|
||||
const {modelValue, searchResults} = toRefs(props)
|
||||
|
||||
watch(
|
||||
modelValue,
|
||||
(value) => setSelectedObject(value),
|
||||
{
|
||||
immediate: true,
|
||||
deep: true,
|
||||
},
|
||||
)
|
||||
|
||||
const searchResultsVisible = computed(() => {
|
||||
if (query.value === '' && !props.showEmpty) {
|
||||
return false
|
||||
}
|
||||
|
||||
return showSearchResults.value && (
|
||||
(filteredSearchResults.value.length > 0) ||
|
||||
(props.creatable && query.value !== '')
|
||||
)
|
||||
})
|
||||
|
||||
const creatableAvailable = computed(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const hasResult = filteredSearchResults.value.some((elem: any) => elementInResults(elem, props.label, query.value))
|
||||
const hasQueryAlreadyAdded = Array.isArray(internalValue.value) && internalValue.value.some(elem => elementInResults(elem, props.label, query.value))
|
||||
|
||||
return props.creatable
|
||||
&& query.value !== ''
|
||||
&& !(hasResult || hasQueryAlreadyAdded)
|
||||
})
|
||||
|
||||
const filteredSearchResults = computed(() => {
|
||||
const currentInternal = internalValue.value
|
||||
if (props.multiple && currentInternal !== null && Array.isArray(currentInternal)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return searchResults.value.filter((item: any) => !currentInternal.some(e => e === item))
|
||||
}
|
||||
|
||||
return searchResults.value
|
||||
})
|
||||
|
||||
const hasMultiple = computed(() => {
|
||||
return props.multiple && Array.isArray(internalValue.value) && internalValue.value.length > 0
|
||||
})
|
||||
|
||||
const removalAvailable = computed(() => !props.multiple && internalValue.value !== null && query.value !== '')
|
||||
function resetSelectedValue() {
|
||||
select(null)
|
||||
}
|
||||
|
||||
const searchInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// Searching will be triggered with a 200ms delay to avoid searching on every keyup event.
|
||||
function search() {
|
||||
|
||||
// Updating the query with a binding does not work on mobile for some reason,
|
||||
// getting the value manual does.
|
||||
query.value = searchInput.value?.value || ''
|
||||
|
||||
if (searchTimeout.value !== null) {
|
||||
clearTimeout(searchTimeout.value)
|
||||
searchTimeout.value = null
|
||||
}
|
||||
|
||||
localLoading.value = true
|
||||
|
||||
searchTimeout.value = setTimeout(() => {
|
||||
emit('search', query.value)
|
||||
setTimeout(() => {
|
||||
localLoading.value = false
|
||||
}, 100) // The duration of the loading timeout of the services
|
||||
showSearchResults.value = true
|
||||
}, props.searchDelay)
|
||||
}
|
||||
|
||||
const multiselectRoot = ref<HTMLElement | null>(null)
|
||||
|
||||
function hideSearchResultsHandler(e: MouseEvent) {
|
||||
closeWhenClickedOutside(e, multiselectRoot.value, closeSearchResults)
|
||||
}
|
||||
|
||||
function closeSearchResults() {
|
||||
showSearchResults.value = false
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
// We need the timeout to avoid the hideSearchResultsHandler hiding the search results right after the input
|
||||
// is focused. That would lead to flickering pre-loaded search results and hiding them right after showing.
|
||||
setTimeout(() => {
|
||||
showSearchResults.value = true
|
||||
}, 10)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function select(object: { [key: string]: any } | null) {
|
||||
if (props.multiple) {
|
||||
if (internalValue.value === null) {
|
||||
internalValue.value = []
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(internalValue.value as any[]).push(object)
|
||||
} else {
|
||||
internalValue.value = object
|
||||
}
|
||||
|
||||
emit('update:modelValue', internalValue.value)
|
||||
emit('select', object)
|
||||
setSelectedObject(object)
|
||||
if (props.closeAfterSelect && filteredSearchResults.value.length > 0 && !creatableAvailable.value) {
|
||||
closeSearchResults()
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function setSelectedObject(object: string | { [id: string]: any } | null, resetOnly = false) {
|
||||
internalValue.value = object
|
||||
|
||||
// We assume we're getting an array when multiple is enabled and can therefore leave the query
|
||||
// value etc as it is
|
||||
if (props.multiple) {
|
||||
query.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (object === null) {
|
||||
query.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (resetOnly) {
|
||||
return
|
||||
}
|
||||
|
||||
query.value = props.label !== '' ? object[props.label] : object
|
||||
}
|
||||
|
||||
const results = ref<(Element | ComponentPublicInstance)[]>([])
|
||||
|
||||
function setResult(el: Element | ComponentPublicInstance | null, index: number) {
|
||||
if (el === null) {
|
||||
delete results.value[index]
|
||||
} else {
|
||||
results.value[index] = el
|
||||
}
|
||||
}
|
||||
|
||||
function preSelect(index: number) {
|
||||
if (index < 0) {
|
||||
searchInput.value?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
const elems = results.value[index]
|
||||
if (typeof elems === 'undefined' || elems.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Array.isArray(elems)) {
|
||||
elems[0].focus()
|
||||
return
|
||||
}
|
||||
|
||||
elems.focus()
|
||||
}
|
||||
|
||||
function create() {
|
||||
if (query.value === '') {
|
||||
return
|
||||
}
|
||||
|
||||
emit('create', query.value)
|
||||
setSelectedObject(query.value, true)
|
||||
closeSearchResults()
|
||||
}
|
||||
|
||||
function createOrSelectOnEnter() {
|
||||
if (!creatableAvailable.value && searchResults.value.length === 1) {
|
||||
select(searchResults.value[0])
|
||||
return
|
||||
}
|
||||
|
||||
if (!creatableAvailable.value) {
|
||||
// Check if there's an exact match for our search term
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const exactMatch = filteredSearchResults.value.find((elem: any) => elementInResults(elem, props.label, query.value))
|
||||
if (exactMatch) {
|
||||
select(exactMatch)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
create()
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function remove(item: any) {
|
||||
for (const ind in internalValue.value) {
|
||||
if (internalValue.value[ind] === item) {
|
||||
internalValue.value.splice(ind, 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
emit('update:modelValue', internalValue.value)
|
||||
emit('remove', item)
|
||||
}
|
||||
|
||||
function focus() {
|
||||
searchInput.value?.focus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.multiselect {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
.control.is-loading::after {
|
||||
top: .75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
padding: 0;
|
||||
background: var(--white);
|
||||
border-color: var(--grey-200);
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--grey-300) !important;
|
||||
}
|
||||
|
||||
.input {
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
border: none !important;
|
||||
background: transparent;
|
||||
height: auto;
|
||||
|
||||
&::placeholder {
|
||||
font-style: normal !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.has-multiple .input {
|
||||
max-width: 250px;
|
||||
|
||||
input {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--primary) !important;
|
||||
background: var(--white) !important;
|
||||
}
|
||||
|
||||
// doesn't seem to be used. maybe inside the slot?
|
||||
.loader {
|
||||
margin: 0 .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.has-search-results .input-wrapper {
|
||||
border-radius: $radius $radius 0 0;
|
||||
border-color: var(--primary) !important;
|
||||
background: var(--white) !important;
|
||||
|
||||
&, &:focus-within {
|
||||
border-bottom-color: var(--grey-200) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.search-results {
|
||||
background: var(--white);
|
||||
border-radius: 0 0 $radius $radius;
|
||||
border: 1px solid var(--primary);
|
||||
border-top: none;
|
||||
|
||||
max-height: 50vh;
|
||||
overflow-x: auto;
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
max-width: 100%;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.search-results-inline {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.search-result-button {
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
text-transform: none;
|
||||
font-family: $family-sans-serif;
|
||||
font-weight: normal;
|
||||
padding: .5rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--grey-800);
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
background: var(--grey-100);
|
||||
box-shadow: none !important;
|
||||
|
||||
.hint-text {
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--grey-200);
|
||||
}
|
||||
}
|
||||
|
||||
.search-result {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
padding: .5rem .75rem;
|
||||
}
|
||||
|
||||
|
||||
.hint-text {
|
||||
font-size: .75rem;
|
||||
color: transparent;
|
||||
transition: color $transition;
|
||||
padding-left: .5rem;
|
||||
}
|
||||
|
||||
.has-removal-button {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.removal-button {
|
||||
position: absolute;
|
||||
right: .5rem;
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
85
frontend/src/components/input/password.vue
Normal file
@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="password-field">
|
||||
<input
|
||||
id="password"
|
||||
class="input"
|
||||
name="password"
|
||||
:placeholder="$t('user.auth.passwordPlaceholder')"
|
||||
required
|
||||
:type="passwordFieldType"
|
||||
autocomplete="current-password"
|
||||
:tabindex="props.tabindex"
|
||||
@keyup.enter="e => $emit('submit', e)"
|
||||
@focusout="validate"
|
||||
@input="handleInput"
|
||||
>
|
||||
<BaseButton
|
||||
v-tooltip="passwordFieldType === 'password' ? $t('user.auth.showPassword') : $t('user.auth.hidePassword')"
|
||||
class="password-field-type-toggle"
|
||||
:aria-label="passwordFieldType === 'password' ? $t('user.auth.showPassword') : $t('user.auth.hidePassword')"
|
||||
@click="togglePasswordFieldType"
|
||||
>
|
||||
<icon :icon="passwordFieldType === 'password' ? 'eye' : 'eye-slash'" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
<p
|
||||
v-if="!isValid"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('user.auth.passwordRequired') }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref, watch} from 'vue'
|
||||
import {useDebounceFn} from '@vueuse/core'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
const props = defineProps({
|
||||
tabindex: String,
|
||||
modelValue: String,
|
||||
// This prop is a workaround to trigger validation from the outside when the user never had focus in the input.
|
||||
validateInitially: Boolean,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['submit', 'update:modelValue'])
|
||||
|
||||
const passwordFieldType = ref('password')
|
||||
const password = ref('')
|
||||
const isValid = ref(!props.validateInitially)
|
||||
|
||||
watch(
|
||||
() => props.validateInitially,
|
||||
() => props.validateInitially && validate(),
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
const validate = useDebounceFn(() => {
|
||||
isValid.value = password.value !== ''
|
||||
}, 100)
|
||||
|
||||
function togglePasswordFieldType() {
|
||||
passwordFieldType.value = passwordFieldType.value === 'password'
|
||||
? 'text'
|
||||
: 'password'
|
||||
}
|
||||
|
||||
function handleInput(e: Event) {
|
||||
password.value = (e.target as HTMLInputElement)?.value
|
||||
emit('update:modelValue', password.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.password-field {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.password-field-type-toggle {
|
||||
position: absolute;
|
||||
color: var(--grey-400);
|
||||
top: 50%;
|
||||
right: 1rem;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
</style>
|
19
frontend/src/components/misc/ButtonLink.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<BaseButton class="button-link">
|
||||
<slot />
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.button-link {
|
||||
color: var(--link);
|
||||
|
||||
&:hover {
|
||||
color: var(--link-hover);
|
||||
}
|
||||
}
|
||||
</style>
|
11
frontend/src/components/misc/Card.story.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import Card from './card.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story :layout="{ type: 'grid', width: '200px' }">
|
||||
<Card>
|
||||
Card content
|
||||
</Card>
|
||||
</Story>
|
||||
</template>
|
68
frontend/src/components/misc/CustomTransition.vue
Normal file
@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<transition :name="name">
|
||||
<!-- eslint-disable-next-line -->
|
||||
<slot/>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
name: 'flash-background' | 'fade' | 'width' | 'modal'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$flash-background-duration: 750ms;
|
||||
|
||||
.flash-background-enter-from,
|
||||
.flash-background-enter-active {
|
||||
animation: flash-background $flash-background-duration ease 1;
|
||||
}
|
||||
|
||||
@keyframes flash-background {
|
||||
0% {
|
||||
background: var(--primary-light);
|
||||
}
|
||||
100% {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@keyframes flash-background {
|
||||
0% {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity $transition-duration;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.width-enter-active,
|
||||
.width-leave-active {
|
||||
transition: width $transition-duration;
|
||||
}
|
||||
|
||||
.width-enter-from,
|
||||
.width-leave-to {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.modal-enter,
|
||||
.modal-leave-active {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter .modal-container,
|
||||
.modal-leave-active .modal-container {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
</style>
|
42
frontend/src/components/misc/Done.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isDone"
|
||||
class="is-done"
|
||||
:class="{ 'is-done--small': variant === 'small' }"
|
||||
>
|
||||
{{ $t('task.attributes.done') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type {PropType} from 'vue'
|
||||
type Variants = 'default' | 'small'
|
||||
|
||||
defineProps({
|
||||
isDone: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
variant: {
|
||||
type: String as PropType<Variants>,
|
||||
default: 'default',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.is-done {
|
||||
background: var(--success);
|
||||
color: var(--white);
|
||||
padding: .5rem;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.is-done--small {
|
||||
padding: .2rem .3rem;
|
||||
}
|
||||
</style>
|
191
frontend/src/components/misc/Icon.ts
Normal file
@ -0,0 +1,191 @@
|
||||
import {library} from '@fortawesome/fontawesome-svg-core'
|
||||
import {
|
||||
faAlignLeft,
|
||||
faAngleRight,
|
||||
faAnglesUp,
|
||||
faArchive,
|
||||
faArrowLeft,
|
||||
faArrowUpFromBracket,
|
||||
faBold,
|
||||
faItalic,
|
||||
faStrikethrough,
|
||||
faCode,
|
||||
faBars,
|
||||
faBell,
|
||||
faBolt,
|
||||
faCalendar,
|
||||
faCheck,
|
||||
faCheckDouble,
|
||||
faChessKnight,
|
||||
faChevronDown,
|
||||
faCircleInfo,
|
||||
faCloudDownloadAlt,
|
||||
faCloudUploadAlt,
|
||||
faCocktail,
|
||||
faCoffee,
|
||||
faCog,
|
||||
faEllipsisH,
|
||||
faEllipsisV,
|
||||
faExclamation,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faFillDrip,
|
||||
faFilter,
|
||||
faForward,
|
||||
faGripLines,
|
||||
faHeader,
|
||||
faHistory,
|
||||
faImage,
|
||||
faKeyboard,
|
||||
faLayerGroup,
|
||||
faList,
|
||||
faListOl,
|
||||
faLock,
|
||||
faPaperclip,
|
||||
faPaste,
|
||||
faPen,
|
||||
faPencilAlt,
|
||||
faPercent,
|
||||
faPlay,
|
||||
faPlus,
|
||||
faPowerOff,
|
||||
faSearch,
|
||||
faShareAlt,
|
||||
faSignOutAlt,
|
||||
faSitemap,
|
||||
faSort,
|
||||
faSortUp,
|
||||
faStar as faStarSolid,
|
||||
faStop,
|
||||
faTachometerAlt,
|
||||
faTags,
|
||||
faTasks,
|
||||
faTh,
|
||||
faTimes,
|
||||
faTrashAlt,
|
||||
faUser,
|
||||
faUsers,
|
||||
faQuoteRight,
|
||||
faListUl,
|
||||
faLink,
|
||||
faUndo,
|
||||
faRedo,
|
||||
faUnlink,
|
||||
faParagraph,
|
||||
faTable,
|
||||
faX, faArrowTurnDown, faListCheck, faXmark, faXmarksLines, faFont, faRulerHorizontal, faUnderline,
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import {
|
||||
faBellSlash,
|
||||
faCalendarAlt,
|
||||
faCheckSquare,
|
||||
faClock,
|
||||
faComments,
|
||||
faFileImage,
|
||||
faSave,
|
||||
faSquareCheck,
|
||||
faStar,
|
||||
faSun,
|
||||
faTimesCircle,
|
||||
faCircleQuestion,
|
||||
} from '@fortawesome/free-regular-svg-icons'
|
||||
import {FontAwesomeIcon} from '@fortawesome/vue-fontawesome'
|
||||
|
||||
import type {FontAwesomeIcon as FontAwesomeIconFixedTypes} from '@/types/vue-fontawesome'
|
||||
|
||||
library.add(faBold)
|
||||
library.add(faUndo)
|
||||
library.add(faRedo)
|
||||
library.add(faItalic)
|
||||
library.add(faLink)
|
||||
library.add(faUnlink)
|
||||
library.add(faParagraph)
|
||||
library.add(faSquareCheck)
|
||||
library.add(faTable)
|
||||
library.add(faFileImage)
|
||||
library.add(faCheckSquare)
|
||||
library.add(faStrikethrough)
|
||||
library.add(faCode)
|
||||
library.add(faQuoteRight)
|
||||
library.add(faListUl)
|
||||
library.add(faAlignLeft)
|
||||
library.add(faAngleRight)
|
||||
library.add(faArchive)
|
||||
library.add(faArrowLeft)
|
||||
library.add(faBars)
|
||||
library.add(faBell)
|
||||
library.add(faBellSlash)
|
||||
library.add(faCalendar)
|
||||
library.add(faCalendarAlt)
|
||||
library.add(faCheck)
|
||||
library.add(faCheckDouble)
|
||||
library.add(faChessKnight)
|
||||
library.add(faChevronDown)
|
||||
library.add(faCircleInfo)
|
||||
library.add(faCircleQuestion)
|
||||
library.add(faClock)
|
||||
library.add(faCloudDownloadAlt)
|
||||
library.add(faCloudUploadAlt)
|
||||
library.add(faCocktail)
|
||||
library.add(faCoffee)
|
||||
library.add(faCog)
|
||||
library.add(faComments)
|
||||
library.add(faEllipsisH)
|
||||
library.add(faEllipsisV)
|
||||
library.add(faExclamation)
|
||||
library.add(faEye)
|
||||
library.add(faEyeSlash)
|
||||
library.add(faFillDrip)
|
||||
library.add(faFilter)
|
||||
library.add(faForward)
|
||||
library.add(faGripLines)
|
||||
library.add(faHeader)
|
||||
library.add(faHistory)
|
||||
library.add(faImage)
|
||||
library.add(faKeyboard)
|
||||
library.add(faLayerGroup)
|
||||
library.add(faList)
|
||||
library.add(faListOl)
|
||||
library.add(faLock)
|
||||
library.add(faPaperclip)
|
||||
library.add(faPaste)
|
||||
library.add(faPen)
|
||||
library.add(faPencilAlt)
|
||||
library.add(faPercent)
|
||||
library.add(faPlay)
|
||||
library.add(faPlus)
|
||||
library.add(faPowerOff)
|
||||
library.add(faSave)
|
||||
library.add(faSearch)
|
||||
library.add(faShareAlt)
|
||||
library.add(faSignOutAlt)
|
||||
library.add(faSitemap)
|
||||
library.add(faSort)
|
||||
library.add(faSortUp)
|
||||
library.add(faStar)
|
||||
library.add(faStarSolid)
|
||||
library.add(faStop)
|
||||
library.add(faSun)
|
||||
library.add(faTachometerAlt)
|
||||
library.add(faTags)
|
||||
library.add(faTasks)
|
||||
library.add(faTh)
|
||||
library.add(faTimes)
|
||||
library.add(faTimesCircle)
|
||||
library.add(faTrashAlt)
|
||||
library.add(faUser)
|
||||
library.add(faUsers)
|
||||
library.add(faArrowUpFromBracket)
|
||||
library.add(faX)
|
||||
library.add(faAnglesUp)
|
||||
library.add(faBolt)
|
||||
library.add(faArrowTurnDown)
|
||||
library.add(faListCheck)
|
||||
library.add(faXmark)
|
||||
library.add(faXmarksLines)
|
||||
library.add(faFont)
|
||||
library.add(faRulerHorizontal)
|
||||
library.add(faUnderline)
|
||||
|
||||
// overwriting the wrong types
|
||||
export default FontAwesomeIcon as unknown as FontAwesomeIconFixedTypes
|
40
frontend/src/components/misc/OpenQuickActions.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {onBeforeUnmount, onMounted} from 'vue'
|
||||
import {eventToHotkeyString} from '@github/hotkey'
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
|
||||
// See https://github.com/github/hotkey/discussions/85#discussioncomment-5214660
|
||||
function openQuickActionsViaHotkey(event) {
|
||||
const hotkeyString = eventToHotkeyString(event)
|
||||
if (!hotkeyString) return
|
||||
if (hotkeyString !== 'Control+k' && hotkeyString !== 'Meta+k') return
|
||||
event.preventDefault()
|
||||
|
||||
openQuickActions()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', openQuickActionsViaHotkey)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', openQuickActionsViaHotkey)
|
||||
})
|
||||
|
||||
function openQuickActions() {
|
||||
baseStore.setQuickActionsActive(true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseButton
|
||||
class="trigger-button"
|
||||
:title="$t('keyboardShortcuts.quickSearch')"
|
||||
@click="openQuickActions"
|
||||
>
|
||||
<icon icon="search" />
|
||||
</BaseButton>
|
||||
</template>
|
15
frontend/src/components/misc/ProgressBar.story.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
|
||||
import ProgressBar from './ProgressBar.vue'
|
||||
|
||||
const value = ref(50)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story>
|
||||
<Variant title="Default">
|
||||
<ProgressBar :value="value" />
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
139
frontend/src/components/misc/ProgressBar.vue
Normal file
@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<progress
|
||||
class="progress-bar"
|
||||
:class="{
|
||||
'is-small': isSmall,
|
||||
'is-primary': isPrimary,
|
||||
}"
|
||||
:value="value"
|
||||
max="100"
|
||||
>
|
||||
{{ value }}%
|
||||
</progress>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {defineProps} from 'vue'
|
||||
|
||||
defineProps({
|
||||
value: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
isSmall: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isPrimary: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.progress-bar {
|
||||
--progress-height: #{$size-normal};
|
||||
--progress-bar-background-color: var(--border-light, #{$border-light});
|
||||
--progress-value-background-color: var(--grey-500, #{$text});
|
||||
--progress-border-radius: #{$radius};
|
||||
--progress-indeterminate-duration: 1.5s;
|
||||
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: var(--progress-border-radius);
|
||||
height: var(--progress-height);
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
min-width: 6vw;
|
||||
|
||||
width: 50px;
|
||||
margin: 0 .5rem 0 0;
|
||||
flex: 3 1 auto;
|
||||
|
||||
&::-moz-progress-bar,
|
||||
&::-webkit-progress-value {
|
||||
background: var(--progress-value-background-color);
|
||||
}
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
margin: 0.5rem 0 0 0;
|
||||
order: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&::-webkit-progress-bar {
|
||||
background-color: var(--progress-bar-background-color);
|
||||
}
|
||||
|
||||
&::-webkit-progress-value {
|
||||
background-color: var(--progress-value-background-color);
|
||||
}
|
||||
|
||||
&::-moz-progress-bar {
|
||||
background-color: var(--progress-value-background-color);
|
||||
}
|
||||
|
||||
&::-ms-fill {
|
||||
background-color: var(--progress-value-background-color);
|
||||
border: none;
|
||||
}
|
||||
|
||||
// Colors
|
||||
@each $name, $pair in $colors {
|
||||
$color: nth($pair, 1);
|
||||
&.is-#{$name} {
|
||||
--progress-value-background-color: var(--#{$name}, #{$color});
|
||||
|
||||
&:indeterminate {
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--#{$name}, #{$color}) 30%,
|
||||
var(--progress-bar-background-color) 30%
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:indeterminate {
|
||||
animation-duration: var(--progress-indeterminate-duration);
|
||||
animation-iteration-count: infinite;
|
||||
animation-name: moveIndeterminate;
|
||||
animation-timing-function: linear;
|
||||
background-color: var(--progress-bar-background-color);
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--text, #{$text}) 30%,
|
||||
var(--progress-bar-background-color) 30%
|
||||
);
|
||||
background-position: top left;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 150% 150%;
|
||||
|
||||
&::-webkit-progress-bar {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&::-moz-progress-bar {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&::-ms-fill {
|
||||
animation-name: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-small {
|
||||
--progress-height: #{$size-small};
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes moveIndeterminate {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
</style>
|
145
frontend/src/components/misc/api-config.vue
Normal file
@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div class="api-config">
|
||||
<div v-if="configureApi">
|
||||
<label
|
||||
class="label"
|
||||
for="api-url"
|
||||
>{{ $t('apiConfig.url') }}</label>
|
||||
<div class="field has-addons">
|
||||
<div class="control is-expanded">
|
||||
<input
|
||||
id="api-url"
|
||||
v-model="apiUrl"
|
||||
v-focus
|
||||
class="input"
|
||||
:placeholder="$t('apiConfig.urlPlaceholder')"
|
||||
required
|
||||
type="url"
|
||||
@keyup.enter="setApiUrl"
|
||||
>
|
||||
</div>
|
||||
<div class="control">
|
||||
<x-button
|
||||
:disabled="apiUrl === '' || undefined"
|
||||
@click="setApiUrl"
|
||||
>
|
||||
{{ $t('apiConfig.change') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="api-url-info"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="apiConfig.use"
|
||||
scope="global"
|
||||
>
|
||||
<span
|
||||
v-tooltip="apiUrl"
|
||||
class="url"
|
||||
> {{ apiDomain }} </span>
|
||||
</i18n-t>
|
||||
<br>
|
||||
<ButtonLink
|
||||
class="api-config__change-button"
|
||||
@click="() => (configureApi = true)"
|
||||
>
|
||||
{{ $t('apiConfig.change') }}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
|
||||
<Message
|
||||
v-if="errorMsg !== ''"
|
||||
variant="danger"
|
||||
class="mt-2"
|
||||
>
|
||||
{{ errorMsg }}
|
||||
</Message>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, watch} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {parseURL} from 'ufo'
|
||||
|
||||
import {checkAndSetApiUrl} from '@/helpers/checkAndSetApiUrl'
|
||||
import {success} from '@/message'
|
||||
|
||||
import Message from '@/components/misc/message.vue'
|
||||
import ButtonLink from '@/components/misc/ButtonLink.vue'
|
||||
|
||||
const props = defineProps({
|
||||
configureOpen: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['foundApi'])
|
||||
|
||||
const apiUrl = ref(window.API_URL)
|
||||
const configureApi = ref(window.API_URL === '')
|
||||
|
||||
// Because we're only using this to parse the hostname, it should be fine to just prefix with http://
|
||||
// regardless of whether the url is actually reachable under http.
|
||||
const apiDomain = computed(() => parseURL(apiUrl.value, 'http://').host || parseURL(window.location.href).host)
|
||||
|
||||
watch(() => props.configureOpen, (value) => {
|
||||
configureApi.value = value
|
||||
}, {immediate: true})
|
||||
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const errorMsg = ref('')
|
||||
|
||||
async function setApiUrl() {
|
||||
if (apiUrl.value === '') {
|
||||
// Don't try to check and set an empty url
|
||||
errorMsg.value = t('apiConfig.urlRequired')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const url = await checkAndSetApiUrl(apiUrl.value)
|
||||
|
||||
if (url === '') {
|
||||
// If the config setter function could not figure out a url
|
||||
throw new Error('URL cannot be empty.')
|
||||
}
|
||||
|
||||
// Set it + save it to local storage to save us the hoops
|
||||
errorMsg.value = ''
|
||||
apiUrl.value = url
|
||||
success({message: t('apiConfig.success', {domain: apiDomain.value})})
|
||||
configureApi.value = false
|
||||
emit('foundApi', apiUrl.value)
|
||||
} catch (e) {
|
||||
// Still not found, url is still invalid
|
||||
errorMsg.value = t('apiConfig.error', {domain: apiDomain.value})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.api-config {
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
|
||||
.api-url-info {
|
||||
font-size: .9rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.url {
|
||||
border-bottom: 1px dashed var(--primary);
|
||||
}
|
||||
|
||||
.api-config__change-button {
|
||||
display: inline-block;
|
||||
color: var(--link);
|
||||
}
|
||||
</style>
|
113
frontend/src/components/misc/card.vue
Normal file
@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div
|
||||
class="card"
|
||||
:class="{'has-no-shadow': !shadow}"
|
||||
>
|
||||
<header
|
||||
v-if="title !== ''"
|
||||
class="card-header"
|
||||
>
|
||||
<p class="card-header-title">
|
||||
{{ title }}
|
||||
</p>
|
||||
<BaseButton
|
||||
v-if="hasClose"
|
||||
v-tooltip="$t('misc.close')"
|
||||
class="card-header-icon"
|
||||
:aria-label="$t('misc.close')"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="closeIcon" />
|
||||
</span>
|
||||
</BaseButton>
|
||||
</header>
|
||||
<div
|
||||
class="card-content loader-container"
|
||||
:class="{
|
||||
'p-0': !padding,
|
||||
'is-loading': loading
|
||||
}"
|
||||
>
|
||||
<div :class="{'content': hasContent}">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer
|
||||
v-if="$slots.footer"
|
||||
class="card-footer"
|
||||
>
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {PropType} from 'vue'
|
||||
import type {IconProp} from '@fortawesome/fontawesome-svg-core'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
padding: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
hasClose: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
closeIcon: {
|
||||
type: String as PropType<IconProp>,
|
||||
default: 'times',
|
||||
},
|
||||
shadow: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
hasContent: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
defineEmits(['close'])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card {
|
||||
background-color: var(--white);
|
||||
border-radius: $radius;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--card-border-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
||||
@media print {
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
box-shadow: none;
|
||||
border-bottom: 1px solid var(--card-border-color);
|
||||
border-radius: $radius $radius 0 0;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
background-color: var(--grey-50);
|
||||
border-top: 0;
|
||||
padding: var(--modal-card-head-padding);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
24
frontend/src/components/misc/colorBubble.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<span
|
||||
:style="{backgroundColor: color }"
|
||||
class="color-bubble"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { DataType } from 'csstype'
|
||||
|
||||
defineProps< {
|
||||
color: DataType.Color,
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.color-bubble {
|
||||
display: inline-block;
|
||||
border-radius: 100%;
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
96
frontend/src/components/misc/create-edit.vue
Normal file
@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<modal
|
||||
:overflow="true"
|
||||
:wide="wide"
|
||||
@close="$router.back()"
|
||||
>
|
||||
<card
|
||||
:title="title"
|
||||
:shadow="false"
|
||||
:padding="false"
|
||||
class="has-text-left"
|
||||
:has-close="true"
|
||||
:loading="loading"
|
||||
@close="$router.back()"
|
||||
>
|
||||
<div class="p-4">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<slot name="footer">
|
||||
<x-button
|
||||
v-if="tertiary !== ''"
|
||||
:shadow="false"
|
||||
variant="tertiary"
|
||||
@click.prevent.stop="$emit('tertiary')"
|
||||
>
|
||||
{{ tertiary }}
|
||||
</x-button>
|
||||
<x-button
|
||||
variant="secondary"
|
||||
@click.prevent.stop="$router.back()"
|
||||
>
|
||||
{{ $t('misc.cancel') }}
|
||||
</x-button>
|
||||
<x-button
|
||||
v-if="hasPrimaryAction"
|
||||
variant="primary"
|
||||
:icon="primaryIcon"
|
||||
:disabled="primaryDisabled || loading"
|
||||
class="ml-2"
|
||||
@click.prevent.stop="primary()"
|
||||
>
|
||||
{{ primaryLabel || $t('misc.create') }}
|
||||
</x-button>
|
||||
</slot>
|
||||
</template>
|
||||
</card>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {PropType} from 'vue'
|
||||
import type {IconProp} from '@fortawesome/fontawesome-svg-core'
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
primaryLabel: {
|
||||
type: String,
|
||||
},
|
||||
primaryIcon: {
|
||||
type: String as PropType<IconProp>,
|
||||
default: 'plus',
|
||||
},
|
||||
primaryDisabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hasPrimaryAction: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
tertiary: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
wide: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['create', 'primary', 'tertiary'])
|
||||
|
||||
function primary() {
|
||||
emit('create')
|
||||
emit('primary')
|
||||
}
|
||||
</script>
|
65
frontend/src/components/misc/dropdown-item.vue
Normal file
@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<BaseButton class="dropdown-item">
|
||||
<span
|
||||
v-if="icon"
|
||||
class="icon is-small"
|
||||
:class="iconClass"
|
||||
>
|
||||
<Icon :icon="icon" />
|
||||
</span>
|
||||
<span>
|
||||
<slot />
|
||||
</span>
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import BaseButton, {type BaseButtonProps} from '@/components/base//BaseButton.vue'
|
||||
import Icon from '@/components/misc/Icon'
|
||||
import type {IconProp} from '@fortawesome/fontawesome-svg-core'
|
||||
|
||||
export interface DropDownItemProps extends /* @vue-ignore */ BaseButtonProps {
|
||||
icon?: IconProp,
|
||||
iconClass?: object | string,
|
||||
}
|
||||
|
||||
defineProps<DropDownItemProps>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dropdown-item {
|
||||
color: var(--text);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
padding: $item-padding;
|
||||
position: relative;
|
||||
text-align: inherit;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: left !important;
|
||||
|
||||
&.is-active {
|
||||
background-color: var(--link);
|
||||
color: var(--link-invert);
|
||||
}
|
||||
|
||||
&:hover:not(.is-disabled) {
|
||||
background-color: var(--grey-100);
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
padding-right: .5rem;
|
||||
color: var(--grey-300);
|
||||
}
|
||||
|
||||
.has-text-danger .icon {
|
||||
color: var(--danger) !important;
|
||||
}
|
||||
</style>
|
104
frontend/src/components/misc/dropdown.vue
Normal file
@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div
|
||||
ref="dropdown"
|
||||
class="dropdown"
|
||||
>
|
||||
<slot
|
||||
name="trigger"
|
||||
:close="close"
|
||||
:toggle-open="toggleOpen"
|
||||
:open="open"
|
||||
>
|
||||
<BaseButton
|
||||
class="dropdown-trigger is-flex"
|
||||
@click="toggleOpen"
|
||||
>
|
||||
<icon
|
||||
:icon="triggerIcon"
|
||||
class="icon"
|
||||
/>
|
||||
</BaseButton>
|
||||
</slot>
|
||||
|
||||
<CustomTransition name="fade">
|
||||
<div
|
||||
v-if="open"
|
||||
class="dropdown-menu"
|
||||
>
|
||||
<div class="dropdown-content">
|
||||
<slot :close="close" />
|
||||
</div>
|
||||
</div>
|
||||
</CustomTransition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, type PropType} from 'vue'
|
||||
import {onClickOutside} from '@vueuse/core'
|
||||
import type {IconProp} from '@fortawesome/fontawesome-svg-core'
|
||||
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
defineProps({
|
||||
triggerIcon: {
|
||||
type: String as PropType<IconProp>,
|
||||
default: 'ellipsis-h',
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const open = ref(false)
|
||||
|
||||
function close() {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function toggleOpen() {
|
||||
open.value = !open.value
|
||||
}
|
||||
|
||||
const dropdown = ref()
|
||||
onClickOutside(dropdown, (e: Event) => {
|
||||
if (!open.value) {
|
||||
return
|
||||
}
|
||||
close()
|
||||
emit('close', e)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dropdown {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
min-width: 12rem;
|
||||
padding-top: 4px;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
z-index: 20;
|
||||
display: block;
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
background-color: var(--scheme-main);
|
||||
border-radius: $radius;
|
||||
padding-bottom: .5rem;
|
||||
padding-top: .5rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
background-color: var(--border-light);
|
||||
border: none;
|
||||
display: block;
|
||||
height: 1px;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
</style>
|
30
frontend/src/components/misc/error.vue
Normal file
@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<Message variant="danger">
|
||||
<i18n-t
|
||||
keypath="loadingError.failed"
|
||||
scope="global"
|
||||
>
|
||||
<ButtonLink @click="reload">
|
||||
{{ $t('loadingError.tryAgain') }}
|
||||
</ButtonLink>
|
||||
<ButtonLink href="https://vikunja.io/contact/">
|
||||
{{ $t('loadingError.contact') }}
|
||||
</ButtonLink>
|
||||
</i18n-t>
|
||||
</Message>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Message from '@/components/misc/message.vue'
|
||||
import ButtonLink from '@/components/misc/ButtonLink.vue'
|
||||
|
||||
function reload() {
|
||||
window.location.reload()
|
||||
}
|
||||
</script>
|
225
frontend/src/components/misc/flatpickr/Flatpickr.vue
Normal file
@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<input
|
||||
v-bind="attrs"
|
||||
ref="root"
|
||||
type="text"
|
||||
data-input
|
||||
:disabled="disabled"
|
||||
>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import flatpickr from 'flatpickr'
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
|
||||
// FIXME: Not sure how to alias these correctly
|
||||
// import Options = Flatpickr.Options doesn't work
|
||||
type Hook = flatpickr.Options.Hook
|
||||
type HookKey = flatpickr.Options.HookKey
|
||||
type Options = flatpickr.Options.Options
|
||||
type DateOption = flatpickr.Options.DateOption
|
||||
|
||||
function camelToKebab(string: string) {
|
||||
return string.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
|
||||
}
|
||||
|
||||
function arrayify<T = unknown>(obj: T) {
|
||||
return obj instanceof Array
|
||||
? obj
|
||||
: [obj]
|
||||
}
|
||||
|
||||
function nullify<T = unknown>(value: T) {
|
||||
return (value && (value as unknown[]).length)
|
||||
? value
|
||||
: null
|
||||
}
|
||||
|
||||
// Events to emit, copied from flatpickr source
|
||||
const includedEvents = [
|
||||
'onChange',
|
||||
'onClose',
|
||||
'onDestroy',
|
||||
'onMonthChange',
|
||||
'onOpen',
|
||||
'onYearChange',
|
||||
] as HookKey[]
|
||||
|
||||
// Let's not emit these events by default
|
||||
const excludedEvents = [
|
||||
'onValueUpdate',
|
||||
'onDayCreate',
|
||||
'onParseConfig',
|
||||
'onReady',
|
||||
'onPreCalendarPosition',
|
||||
'onKeyDown',
|
||||
] as HookKey[]
|
||||
|
||||
// Keep a copy of all events for later use
|
||||
const allEvents = includedEvents.concat(excludedEvents)
|
||||
|
||||
export default {inheritAttrs: false}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, onBeforeUnmount, onMounted, ref, toRefs, useAttrs, watch, watchEffect, type PropType} from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number, Date, Array] as PropType<DateOption | DateOption[] | null>,
|
||||
default: null,
|
||||
},
|
||||
// https://flatpickr.js.org/options/
|
||||
config: {
|
||||
type: Object as PropType<Options>,
|
||||
default: () => ({
|
||||
defaultDate: null,
|
||||
wrap: false,
|
||||
}),
|
||||
},
|
||||
events: {
|
||||
type: Array as PropType<HookKey[]>,
|
||||
default: () => includedEvents,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'blur',
|
||||
'update:modelValue',
|
||||
...allEvents.map(camelToKebab),
|
||||
])
|
||||
|
||||
const {modelValue, config, disabled} = toRefs(props)
|
||||
|
||||
// bind listener like onBlur
|
||||
const attrs = useAttrs()
|
||||
|
||||
const root = ref<HTMLInputElement | null>(null)
|
||||
const fp = ref<flatpickr.Instance | null>(null)
|
||||
const safeConfig = ref<Options>({...props.config})
|
||||
|
||||
function prepareConfig() {
|
||||
// Don't mutate original object on parent component
|
||||
const newConfig: Options = {...props.config}
|
||||
|
||||
props.events.forEach((hook) => {
|
||||
// Respect global callbacks registered via setDefault() method
|
||||
const globalCallbacks = flatpickr.defaultConfig[hook] || []
|
||||
|
||||
// Inject our own method along with user callback
|
||||
const localCallback: Hook = (...args) => emit(camelToKebab(hook), ...args)
|
||||
|
||||
// Overwrite with merged array
|
||||
newConfig[hook] = arrayify(newConfig[hook] || []).concat(
|
||||
globalCallbacks,
|
||||
localCallback,
|
||||
)
|
||||
})
|
||||
|
||||
// Watch for value changed by date-picker itself and notify parent component
|
||||
const onChange: Hook = (dates) => emit('update:modelValue', dates)
|
||||
newConfig['onChange'] = arrayify(newConfig['onChange'] || []).concat(onChange)
|
||||
|
||||
// Flatpickr does not emit input event in some cases
|
||||
// const onClose: Hook = (_selectedDates, dateStr) => emit('update:modelValue', dateStr)
|
||||
// newConfig['onClose'] = arrayify(newConfig['onClose'] || []).concat(onClose)
|
||||
|
||||
// Set initial date without emitting any event
|
||||
newConfig.defaultDate = props.modelValue || newConfig.defaultDate
|
||||
|
||||
safeConfig.value = newConfig
|
||||
|
||||
return safeConfig.value
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (
|
||||
fp.value || // Return early if flatpickr is already loaded
|
||||
!root.value // our input needs to be mounted
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
prepareConfig()
|
||||
|
||||
/**
|
||||
* Get the HTML node where flatpickr to be attached
|
||||
* Bind on parent element if wrap is true
|
||||
*/
|
||||
const element = props.config.wrap
|
||||
? root.value.parentNode
|
||||
: root.value
|
||||
|
||||
// Init flatpickr
|
||||
fp.value = flatpickr(element, safeConfig.value)
|
||||
})
|
||||
onBeforeUnmount(() => fp.value?.destroy())
|
||||
|
||||
watch(config, () => {
|
||||
if (!fp.value) return
|
||||
// Workaround: Don't pass hooks to configs again otherwise
|
||||
// previously registered hooks will stop working
|
||||
// Notice: we are looping through all events
|
||||
// This also means that new callbacks can not be passed once component has been initialized
|
||||
allEvents.forEach((hook) => {
|
||||
delete safeConfig.value?.[hook]
|
||||
})
|
||||
fp.value.set(safeConfig.value)
|
||||
|
||||
// Passing these properties in `set()` method will cause flatpickr to trigger some callbacks
|
||||
const configCallbacks = ['locale', 'showMonths'] as (keyof Options)[]
|
||||
|
||||
// Workaround: Allow to change locale dynamically
|
||||
configCallbacks.forEach(name => {
|
||||
if (typeof safeConfig.value?.[name] !== 'undefined' && fp.value) {
|
||||
fp.value.set(name, safeConfig.value[name])
|
||||
}
|
||||
})
|
||||
}, {deep: true})
|
||||
|
||||
const fpInput = computed(() => {
|
||||
if (!fp.value) return
|
||||
return fp.value.altInput || fp.value.input
|
||||
})
|
||||
|
||||
/**
|
||||
* init blur event
|
||||
* (is required by many validation libraries)
|
||||
*/
|
||||
function onBlur(event: Event) {
|
||||
emit('blur', nullify((event.target as HTMLInputElement).value))
|
||||
}
|
||||
|
||||
watchEffect(() => fpInput.value?.addEventListener('blur', onBlur))
|
||||
onBeforeUnmount(() => fpInput.value?.removeEventListener('blur', onBlur))
|
||||
|
||||
/**
|
||||
* Watch for the disabled property and sets the value to the real input.
|
||||
*/
|
||||
watchEffect(() => {
|
||||
if (disabled.value) {
|
||||
fpInput.value?.setAttribute('disabled', '')
|
||||
} else {
|
||||
fpInput.value?.removeAttribute('disabled')
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Watch for changes from parent component and update DOM
|
||||
*/
|
||||
watch(
|
||||
modelValue,
|
||||
newValue => {
|
||||
// Prevent updates if v-model value is same as input's current value
|
||||
if (!root.value || newValue === nullify(root.value.value)) return
|
||||
// Make sure we have a flatpickr instance and
|
||||
// notify flatpickr instance that there is a change in value
|
||||
fp.value?.setDate(newValue, true)
|
||||
},
|
||||
{deep: true},
|
||||
)
|
||||
</script>
|
89
frontend/src/components/misc/keyboard-shortcuts/index.vue
Normal file
@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<modal @close="close()">
|
||||
<card
|
||||
class="has-background-white keyboard-shortcuts"
|
||||
:shadow="false"
|
||||
:title="$t('keyboardShortcuts.title')"
|
||||
>
|
||||
<template
|
||||
v-for="(s, i) in shortcuts"
|
||||
:key="i"
|
||||
>
|
||||
<h3>{{ $t(s.title) }}</h3>
|
||||
|
||||
<Message
|
||||
v-if="s.available"
|
||||
class="mb-4"
|
||||
>
|
||||
{{
|
||||
typeof s.available === 'undefined' ?
|
||||
$t('keyboardShortcuts.allPages') :
|
||||
(
|
||||
s.available($route)
|
||||
? $t('keyboardShortcuts.currentPageOnly')
|
||||
: $t('keyboardShortcuts.somePagesOnly')
|
||||
)
|
||||
}}
|
||||
</Message>
|
||||
|
||||
<dl class="shortcut-list">
|
||||
<template
|
||||
v-for="(sc, si) in s.shortcuts"
|
||||
:key="si"
|
||||
>
|
||||
<dt class="shortcut-title">
|
||||
{{ $t(sc.title) }}
|
||||
</dt>
|
||||
<Shortcut
|
||||
is="dd"
|
||||
class="shortcut-keys"
|
||||
:keys="sc.keys"
|
||||
:combination="sc.combination && $t(`keyboardShortcuts.${sc.combination}`)"
|
||||
/>
|
||||
</template>
|
||||
</dl>
|
||||
</template>
|
||||
</card>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
|
||||
import Shortcut from '@/components/misc/shortcut.vue'
|
||||
import Message from '@/components/misc/message.vue'
|
||||
|
||||
import {KEYBOARD_SHORTCUTS as shortcuts} from './shortcuts'
|
||||
|
||||
function close() {
|
||||
useBaseStore().setKeyboardShortcutsActive(false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.keyboard-shortcuts {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.message:not(:last-child) {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.message-body {
|
||||
padding: .75rem;
|
||||
}
|
||||
|
||||
.shortcut-list {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
}
|
||||
|
||||
.shortcut-title {
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.shortcut-keys {
|
||||
justify-content: end;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
</style>
|
161
frontend/src/components/misc/keyboard-shortcuts/shortcuts.ts
Normal file
@ -0,0 +1,161 @@
|
||||
import type {RouteLocation} from 'vue-router'
|
||||
|
||||
import {isAppleDevice} from '@/helpers/isAppleDevice'
|
||||
|
||||
const ctrl = isAppleDevice() ? '⌘' : 'ctrl'
|
||||
|
||||
interface Shortcut {
|
||||
title: string
|
||||
keys: string[]
|
||||
combination?: 'then'
|
||||
}
|
||||
|
||||
interface ShortcutGroup {
|
||||
title: string
|
||||
available?: (route: RouteLocation) => boolean
|
||||
shortcuts: Shortcut[]
|
||||
}
|
||||
|
||||
export const KEYBOARD_SHORTCUTS : ShortcutGroup[] = [
|
||||
{
|
||||
title: 'keyboardShortcuts.general',
|
||||
shortcuts: [
|
||||
{
|
||||
title: 'keyboardShortcuts.toggleMenu',
|
||||
keys: [ctrl, 'e'],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.quickSearch',
|
||||
keys: [ctrl, 'k'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.navigation.title',
|
||||
shortcuts: [
|
||||
{
|
||||
title: 'keyboardShortcuts.navigation.overview',
|
||||
keys: ['g', 'o'],
|
||||
combination: 'then',
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.navigation.upcoming',
|
||||
keys: ['g', 'u'],
|
||||
combination: 'then',
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.navigation.projects',
|
||||
keys: ['g', 'p'],
|
||||
combination: 'then',
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.navigation.labels',
|
||||
keys: ['g', 'a'],
|
||||
combination: 'then',
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.navigation.teams',
|
||||
keys: ['g', 'm'],
|
||||
combination: 'then',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'project.kanban.title',
|
||||
available: (route) => route.name === 'project.kanban',
|
||||
shortcuts: [
|
||||
{
|
||||
title: 'keyboardShortcuts.task.done',
|
||||
keys: [ctrl, 'click'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.project.title',
|
||||
available: (route) => (route.name as string)?.startsWith('project.'),
|
||||
shortcuts: [
|
||||
{
|
||||
title: 'keyboardShortcuts.project.switchToListView',
|
||||
keys: ['g', 'l'],
|
||||
combination: 'then',
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.project.switchToGanttView',
|
||||
keys: ['g', 'g'],
|
||||
combination: 'then',
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.project.switchToTableView',
|
||||
keys: ['g', 't'],
|
||||
combination: 'then',
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.project.switchToKanbanView',
|
||||
keys: ['g', 'k'],
|
||||
combination: 'then',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.title',
|
||||
available: (route) => route.name === 'task.detail',
|
||||
shortcuts: [
|
||||
{
|
||||
title: 'keyboardShortcuts.task.done',
|
||||
keys: ['t'],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.assign',
|
||||
keys: ['a'],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.labels',
|
||||
keys: ['l'],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.dueDate',
|
||||
keys: ['d'],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.attachment',
|
||||
keys: ['f'],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.related',
|
||||
keys: ['r'],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.move',
|
||||
keys: ['m'],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.color',
|
||||
keys: ['c'],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.reminder',
|
||||
keys: ['alt', 'r'],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.description',
|
||||
keys: ['e'],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.priority',
|
||||
keys: ['p'],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.delete',
|
||||
keys: ['shift', 'delete'],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.favorite',
|
||||
keys: ['s'],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.save',
|
||||
keys: [ctrl, 's'],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
37
frontend/src/components/misc/legal.vue
Normal file
@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="legal-links">
|
||||
<BaseButton
|
||||
v-if="imprintUrl"
|
||||
:href="imprintUrl"
|
||||
>
|
||||
{{ $t('navigation.imprint') }}
|
||||
</BaseButton>
|
||||
<span v-if="imprintUrl && privacyPolicyUrl"> | </span>
|
||||
<BaseButton
|
||||
v-if="privacyPolicyUrl"
|
||||
:href="privacyPolicyUrl"
|
||||
>
|
||||
{{ $t('navigation.privacy') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed} from 'vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const imprintUrl = computed(() => configStore.legal.imprintUrl)
|
||||
const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.legal-links {
|
||||
margin-top: 1rem;
|
||||
text-align: right;
|
||||
color: var(--grey-300);
|
||||
font-size: 1rem;
|
||||
}
|
||||
</style>
|
48
frontend/src/components/misc/loading.vue
Normal file
@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div
|
||||
class="loader-container is-loading"
|
||||
:class="{'is-small': variant === 'small'}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
inheritAttrs: true,
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const {
|
||||
variant = 'default',
|
||||
} = defineProps<{
|
||||
variant?: 'default' | 'small'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.loader-container {
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
width: 100%;
|
||||
min-width: 600px;
|
||||
max-width: 100vw;
|
||||
|
||||
&.is-loading-small {
|
||||
min-height: 50px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
&.is-small {
|
||||
min-width: 100%;
|
||||
height: 150px;
|
||||
|
||||
&.is-loading::after {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
top: calc(50% - 1.5rem);
|
||||
left: calc(50% - 1.5rem);
|
||||
border-width: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
68
frontend/src/components/misc/message.vue
Normal file
@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="message-wrapper">
|
||||
<div
|
||||
class="message"
|
||||
:class="[variant, textAlignClass]"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, type PropType} from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'info',
|
||||
},
|
||||
textAlign: {
|
||||
type: String as PropType<textAlignVariants>,
|
||||
default: 'left',
|
||||
},
|
||||
})
|
||||
|
||||
const TEXT_ALIGN_MAP = Object.freeze({
|
||||
left: '',
|
||||
center: 'has-text-centered',
|
||||
right: 'has-text-right',
|
||||
})
|
||||
|
||||
type textAlignVariants = keyof typeof TEXT_ALIGN_MAP
|
||||
|
||||
const textAlignClass = computed(() => TEXT_ALIGN_MAP[props.textAlign])
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-wrapper {
|
||||
border-radius: $radius;
|
||||
background: var(--white);
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: .75rem 1rem;
|
||||
border-radius: $radius;
|
||||
}
|
||||
|
||||
.info {
|
||||
border: 1px solid var(--primary);
|
||||
background: hsla(var(--primary-hsl), .05);
|
||||
}
|
||||
|
||||
.danger {
|
||||
border: 1px solid var(--danger);
|
||||
background: hsla(var(--danger-h), var(--danger-s), var(--danger-l), .05);
|
||||
}
|
||||
|
||||
.warning {
|
||||
border: 1px solid var(--warning);
|
||||
background: hsla(var(--warning-h), var(--warning-s), var(--warning-l), .05);
|
||||
}
|
||||
|
||||
.success {
|
||||
border: 1px solid var(--success);
|
||||
background: hsla(var(--success-h), var(--success-s), var(--success-l), .05);
|
||||
}
|
||||
</style>
|
230
frontend/src/components/misc/modal.vue
Normal file
@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<!-- FIXME: transition should not be included in the modal -->
|
||||
<CustomTransition
|
||||
:name="transitionName"
|
||||
appear
|
||||
>
|
||||
<section
|
||||
v-if="enabled"
|
||||
ref="modal"
|
||||
class="modal-mask"
|
||||
:class="[
|
||||
{ 'has-overflow': overflow },
|
||||
variant,
|
||||
]"
|
||||
v-bind="attrs"
|
||||
>
|
||||
<div
|
||||
v-shortcut="'Escape'"
|
||||
class="modal-container"
|
||||
@mousedown.self.prevent.stop="$emit('close')"
|
||||
>
|
||||
<div
|
||||
class="modal-content"
|
||||
:class="{
|
||||
'has-overflow': overflow,
|
||||
'is-wide': wide
|
||||
}"
|
||||
>
|
||||
<BaseButton
|
||||
class="close"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<icon icon="times" />
|
||||
</BaseButton>
|
||||
|
||||
<slot>
|
||||
<div class="header">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<div class="content">
|
||||
<slot name="text" />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<x-button
|
||||
variant="tertiary"
|
||||
class="has-text-danger"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
{{ $t('misc.cancel') }}
|
||||
</x-button>
|
||||
<x-button
|
||||
v-cy="'modalPrimary'"
|
||||
variant="primary"
|
||||
:shadow="false"
|
||||
@click="$emit('submit')"
|
||||
>
|
||||
{{ $t('misc.doit') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</CustomTransition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import {ref, useAttrs, watchEffect} from 'vue'
|
||||
import {useScrollLock} from '@vueuse/core'
|
||||
|
||||
const {
|
||||
enabled = true,
|
||||
overflow,
|
||||
wide,
|
||||
transitionName = 'modal',
|
||||
variant = 'default',
|
||||
} = defineProps<{
|
||||
enabled?: boolean,
|
||||
overflow?: boolean,
|
||||
wide?: boolean,
|
||||
transitionName?: 'modal' | 'fade',
|
||||
variant?: 'default' | 'hint-modal' | 'scrolling',
|
||||
}>()
|
||||
|
||||
defineEmits(['close', 'submit'])
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const modal = ref<HTMLElement | null>(null)
|
||||
const scrollLock = useScrollLock(modal)
|
||||
|
||||
watchEffect(() => {
|
||||
scrollLock.value = enabled
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$modal-margin: 4rem;
|
||||
$modal-width: 1024px;
|
||||
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
z-index: 4000;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, .8);
|
||||
transition: opacity 150ms ease;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
transition: all 150ms ease;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.default .modal-content,
|
||||
.hint-modal .modal-content {
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
margin: 0;
|
||||
top: 25%;
|
||||
transform: translate(-50%, -25%);
|
||||
}
|
||||
|
||||
.header {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
// scrolling-content
|
||||
// used e.g. for <TaskDetailViewModal>
|
||||
.scrolling .modal-content {
|
||||
max-width: $modal-width;
|
||||
width: 100%;
|
||||
margin: $modal-margin auto;
|
||||
|
||||
max-height: none; // reset bulma
|
||||
overflow: visible; // reset bulma
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
max-height: none; // reset bulma
|
||||
margin: $modal-margin auto; // reset bulma
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $desktop) {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.is-wide {
|
||||
max-width: $desktop;
|
||||
width: calc(100% - 2rem);
|
||||
}
|
||||
|
||||
.hint-modal {
|
||||
z-index: 4600;
|
||||
|
||||
:deep(.card-content) {
|
||||
text-align: left;
|
||||
|
||||
.info {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.close {
|
||||
$close-button-padding: 26px;
|
||||
position: fixed;
|
||||
top: .5rem;
|
||||
right: $close-button-padding;
|
||||
color: var(--grey-900);
|
||||
font-size: 2rem;
|
||||
|
||||
@media screen and (min-width: $desktop) and (max-width: calc(#{$desktop } + #{$close-button-min-space})) {
|
||||
top: calc(5px + $modal-margin);
|
||||
right: 50%;
|
||||
// we align the close button to the modal until there is enough space outside for it
|
||||
transform: translateX(calc((#{$modal-width} / 2) - #{$close-button-padding}));
|
||||
}
|
||||
// we can only use light color when there is enough space for the close button next to the modal
|
||||
@media screen and (min-width: calc(#{$desktop } + #{$close-button-min-space})) {
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
@media screen and (min-width: $tablet) and (max-width: #{$desktop + $close-button-min-space}) {
|
||||
top: .75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
// Close icon SVG uses currentColor, change the color to keep it visible
|
||||
.dark .close {
|
||||
color: var(--grey-900);
|
||||
}
|
||||
</style>
|
159
frontend/src/components/misc/no-auth-wrapper.vue
Normal file
@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div class="no-auth-wrapper">
|
||||
<Logo
|
||||
class="logo"
|
||||
width="200"
|
||||
height="58"
|
||||
/>
|
||||
<div class="noauth-container">
|
||||
<section
|
||||
class="image"
|
||||
:class="{'has-message': motd !== ''}"
|
||||
>
|
||||
<Message v-if="motd !== ''">
|
||||
{{ motd }}
|
||||
</Message>
|
||||
<h2 class="image-title">
|
||||
{{ $t('misc.welcomeBack') }}
|
||||
</h2>
|
||||
</section>
|
||||
<section class="content">
|
||||
<div>
|
||||
<h2
|
||||
v-if="title"
|
||||
class="title"
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
<ApiConfig v-if="showApiConfig" />
|
||||
<Message
|
||||
v-if="motd !== ''"
|
||||
class="is-hidden-tablet mb-4"
|
||||
>
|
||||
{{ motd }}
|
||||
</Message>
|
||||
<slot />
|
||||
</div>
|
||||
<Legal />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed} from 'vue'
|
||||
import {useRoute} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import Logo from '@/components/home/Logo.vue'
|
||||
import Message from '@/components/misc/message.vue'
|
||||
import Legal from '@/components/misc/legal.vue'
|
||||
import ApiConfig from '@/components/misc/api-config.vue'
|
||||
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
|
||||
const {
|
||||
showApiConfig = true,
|
||||
} = defineProps<{
|
||||
showApiConfig?: boolean
|
||||
}>()
|
||||
const configStore = useConfigStore()
|
||||
const motd = computed(() => configStore.motd)
|
||||
|
||||
const route = useRoute()
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const title = computed(() => t(route.meta?.title as string || ''))
|
||||
useTitle(() => title.value)
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.no-auth-wrapper {
|
||||
background: var(--site-background) url('@/assets/llama.svg?url') no-repeat fixed bottom left;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
place-items: center;
|
||||
|
||||
@media screen and (max-width: $fullhd) {
|
||||
padding-bottom: 15rem;
|
||||
}
|
||||
}
|
||||
|
||||
.noauth-container {
|
||||
max-width: $desktop;
|
||||
width: 100%;
|
||||
min-height: 60vh;
|
||||
display: flex;
|
||||
background-color: var(--white);
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow: hidden;
|
||||
|
||||
@media screen and (min-width: $desktop) {
|
||||
border-radius: $radius;
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 50%;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
background: url('@/assets/no-auth-image.jpg') no-repeat bottom/cover;
|
||||
position: relative;
|
||||
|
||||
&.has-message {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, .2);
|
||||
}
|
||||
|
||||
> * {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
padding: 2rem 2rem 1.5rem;
|
||||
|
||||
@media screen and (max-width: $desktop) {
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $desktop) {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
max-width: 100%;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.image-title {
|
||||
color: var(--white);
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
</style>
|
5
frontend/src/components/misc/nothing.vue
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<p class="has-text-centered has-text-grey is-italic p-4 mb-4">
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
58
frontend/src/components/misc/notification.vue
Normal file
@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<notifications
|
||||
position="bottom left"
|
||||
:max="2"
|
||||
class="global-notification"
|
||||
>
|
||||
<template #body="{ item, close }">
|
||||
<!-- FIXME: overlay whole notification with button and add event listener on that button instead -->
|
||||
<div
|
||||
class="vue-notification-template vue-notification"
|
||||
:class="[
|
||||
item.type,
|
||||
]"
|
||||
@click="close()"
|
||||
>
|
||||
<div
|
||||
v-if="item.title"
|
||||
class="notification-title"
|
||||
>
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div class="notification-content">
|
||||
<template
|
||||
v-for="(t, k) in item.text"
|
||||
:key="k"
|
||||
>
|
||||
{{ t }}<br>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="item.data?.actions?.length > 0"
|
||||
class="buttons is-right"
|
||||
>
|
||||
<x-button
|
||||
v-for="(action, i) in item.data.actions"
|
||||
:key="'action_' + i"
|
||||
:shadow="false"
|
||||
class="is-small"
|
||||
variant="secondary"
|
||||
@click="action.callback"
|
||||
>
|
||||
{{ action.title }}
|
||||
</x-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</notifications>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vue-notification {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
118
frontend/src/components/misc/pagination.vue
Normal file
@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<nav
|
||||
v-if="totalPages > 1"
|
||||
aria-label="pagination"
|
||||
class="pagination is-centered p-4"
|
||||
role="navigation"
|
||||
>
|
||||
<router-link
|
||||
:disabled="currentPage === 1 || undefined"
|
||||
:to="getRouteForPagination(currentPage - 1)"
|
||||
class="pagination-previous"
|
||||
>
|
||||
{{ $t('misc.previous') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
:disabled="currentPage === totalPages || undefined"
|
||||
:to="getRouteForPagination(currentPage + 1)"
|
||||
class="pagination-next"
|
||||
>
|
||||
{{ $t('misc.next') }}
|
||||
</router-link>
|
||||
<ul class="pagination-list">
|
||||
<li
|
||||
v-for="(p, i) in pages"
|
||||
:key="`page-${i}`"
|
||||
>
|
||||
<span
|
||||
v-if="p.isEllipsis"
|
||||
class="pagination-ellipsis"
|
||||
>…</span>
|
||||
<router-link
|
||||
v-else
|
||||
class="pagination-link"
|
||||
:aria-label="'Goto page ' + p.number"
|
||||
:class="{ 'is-current': p.number === currentPage }"
|
||||
:to="getRouteForPagination(p.number)"
|
||||
>
|
||||
{{ p.number }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed} from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
totalPages: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
currentPage: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
|
||||
function createPagination(totalPages: number, currentPage: number) {
|
||||
const pages = []
|
||||
for (let i = 0; i < totalPages; i++) {
|
||||
|
||||
// Show ellipsis instead of all pages
|
||||
if (
|
||||
i > 0 && // Always at least the first page
|
||||
(i + 1) < totalPages && // And the last page
|
||||
(
|
||||
// And the current with current + 1 and current - 1
|
||||
(i + 1) > currentPage + 1 ||
|
||||
(i + 1) < currentPage - 1
|
||||
)
|
||||
) {
|
||||
// Only add an ellipsis if the last page isn't already one
|
||||
if (pages[i - 1] && !pages[i - 1].isEllipsis) {
|
||||
pages.push({
|
||||
number: 0,
|
||||
isEllipsis: true,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
pages.push({
|
||||
number: i + 1,
|
||||
isEllipsis: false,
|
||||
})
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
function getRouteForPagination(page = 1, type = null) {
|
||||
return {
|
||||
name: type,
|
||||
params: {
|
||||
type: type,
|
||||
},
|
||||
query: {
|
||||
page: page,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const pages = computed(() => createPagination(props.totalPages, props.currentPage))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.pagination {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.pagination-previous,
|
||||
.pagination-next {
|
||||
&:not(:disabled):hover {
|
||||
background: $scheme-main;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
73
frontend/src/components/misc/popup.vue
Normal file
@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<slot
|
||||
name="trigger"
|
||||
:is-open="open"
|
||||
:toggle="toggle"
|
||||
:close="close"
|
||||
/>
|
||||
<div
|
||||
ref="popup"
|
||||
class="popup"
|
||||
:class="{
|
||||
'is-open': open,
|
||||
'has-overflow': props.hasOverflow && open
|
||||
}"
|
||||
>
|
||||
<slot
|
||||
name="content"
|
||||
:is-open="open"
|
||||
:toggle="toggle"
|
||||
:close="close"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
import {onClickOutside} from '@vueuse/core'
|
||||
|
||||
const props = defineProps({
|
||||
hasOverflow: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const open = ref(false)
|
||||
const popup = ref<HTMLElement | null>(null)
|
||||
|
||||
function close() {
|
||||
open.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
open.value = !open.value
|
||||
}
|
||||
|
||||
onClickOutside(popup, () => {
|
||||
if (!open.value) {
|
||||
return
|
||||
}
|
||||
close()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.popup {
|
||||
transition: opacity $transition;
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
z-index: 100;
|
||||
|
||||
&.is-open {
|
||||
opacity: 1;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
171
frontend/src/components/misc/ready.vue
Normal file
@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<!-- This is a workaround to get the sw to "see" the to-be-cached version of the offline background image -->
|
||||
<div
|
||||
class="offline"
|
||||
style="height: 0;width: 0;"
|
||||
/>
|
||||
<div
|
||||
v-if="!online"
|
||||
class="app offline"
|
||||
>
|
||||
<div class="offline-message">
|
||||
<h1 class="title">
|
||||
{{ $t('offline.title') }}
|
||||
</h1>
|
||||
<p>{{ $t('offline.text') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else-if="ready">
|
||||
<slot />
|
||||
</template>
|
||||
<section v-else-if="error !== ''">
|
||||
<NoAuthWrapper :show-api-config="false">
|
||||
<p v-if="error === ERROR_NO_API_URL">
|
||||
{{ $t('ready.noApiUrlConfigured') }}
|
||||
</p>
|
||||
<Message
|
||||
v-else
|
||||
variant="danger"
|
||||
class="mb-4"
|
||||
>
|
||||
<p>
|
||||
{{ $t('ready.errorOccured') }}<br>
|
||||
{{ error }}
|
||||
</p>
|
||||
<p>
|
||||
{{ $t('ready.checkApiUrl') }}
|
||||
</p>
|
||||
</Message>
|
||||
<ApiConfig
|
||||
:configure-open="true"
|
||||
@foundApi="load"
|
||||
/>
|
||||
</NoAuthWrapper>
|
||||
</section>
|
||||
<CustomTransition name="fade">
|
||||
<section
|
||||
v-if="showLoading"
|
||||
class="vikunja-loading"
|
||||
>
|
||||
<Logo class="logo" />
|
||||
<p>
|
||||
<span class="loader-container is-loading-small is-loading" />
|
||||
{{ $t('ready.loading') }}
|
||||
</p>
|
||||
</section>
|
||||
</CustomTransition>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref, computed} from 'vue'
|
||||
import {useRouter, useRoute} from 'vue-router'
|
||||
|
||||
import Logo from '@/assets/logo.svg?component'
|
||||
import ApiConfig from '@/components/misc/api-config.vue'
|
||||
import Message from '@/components/misc/message.vue'
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
|
||||
|
||||
import {ERROR_NO_API_URL, InvalidApiUrlProvidedError, NoApiUrlProvidedError} from '@/helpers/checkAndSetApiUrl'
|
||||
import {useOnline} from '@/composables/useOnline'
|
||||
|
||||
import {getAuthForRoute} from '@/router'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const ready = computed(() => baseStore.ready)
|
||||
const online = useOnline()
|
||||
|
||||
const error = ref('')
|
||||
const showLoading = computed(() => !ready.value && error.value === '')
|
||||
|
||||
const {t} = useI18n()
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
await baseStore.loadApp()
|
||||
baseStore.setReady(true)
|
||||
const redirectTo = await getAuthForRoute(route, authStore)
|
||||
if (typeof redirectTo !== 'undefined') {
|
||||
await router.push(redirectTo)
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof NoApiUrlProvidedError) {
|
||||
error.value = ERROR_NO_API_URL
|
||||
return
|
||||
}
|
||||
if (e instanceof InvalidApiUrlProvidedError) {
|
||||
error.value = t('apiConfig.error')
|
||||
return
|
||||
}
|
||||
error.value = String(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.vikunja-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background: var(--grey-100);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin-bottom: 1rem;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.loader-container {
|
||||
margin-right: 1rem;
|
||||
|
||||
&.is-loading::after {
|
||||
border-left-color: var(--grey-400);
|
||||
border-bottom-color: var(--grey-400);
|
||||
}
|
||||
}
|
||||
|
||||
.offline {
|
||||
background: url('@/assets/llama-nightscape.jpg') no-repeat center;
|
||||
background-size: cover;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.offline-message {
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
width: 100vw;
|
||||
bottom: 5vh;
|
||||
color: $white;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
font-size: 1.5rem;
|
||||
text-align: center;
|
||||
color: $white;
|
||||
font-weight: 700 !important;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
</style>
|
50
frontend/src/components/misc/shortcut.vue
Normal file
@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<component
|
||||
:is="is"
|
||||
class="shortcuts"
|
||||
>
|
||||
<template
|
||||
v-for="(k, i) in keys"
|
||||
:key="i"
|
||||
>
|
||||
<kbd>{{ k }}</kbd>
|
||||
<span v-if="i < keys.length - 1">{{ combination }}</span>
|
||||
</template>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps({
|
||||
keys: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
combination: {
|
||||
type: String,
|
||||
default: '+',
|
||||
},
|
||||
is: {
|
||||
type: String,
|
||||
default: 'div',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.shortcuts {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
kbd {
|
||||
padding: .1rem .35rem;
|
||||
border: 1px solid var(--grey-300);
|
||||
background: var(--grey-100);
|
||||
border-radius: 3px;
|
||||
font-size: .75rem;
|
||||
}
|
||||
|
||||
span {
|
||||
padding: 0 .25rem;
|
||||
}
|
||||
</style>
|
152
frontend/src/components/misc/subscription.vue
Normal file
@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<x-button
|
||||
v-if="type === 'button'"
|
||||
v-tooltip="tooltipText"
|
||||
variant="secondary"
|
||||
:icon="iconName"
|
||||
:disabled="disabled"
|
||||
@click="changeSubscription"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</x-button>
|
||||
<DropdownItem
|
||||
v-else-if="type === 'dropdown'"
|
||||
v-tooltip="tooltipText"
|
||||
:disabled="disabled"
|
||||
:icon="iconName"
|
||||
@click="changeSubscription"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</DropdownItem>
|
||||
<BaseButton
|
||||
v-else
|
||||
v-tooltip="tooltipText"
|
||||
:class="{'is-disabled': disabled}"
|
||||
:disabled="disabled"
|
||||
@click="changeSubscription"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="iconName" />
|
||||
</span>
|
||||
{{ buttonText }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, shallowRef, type PropType} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import DropdownItem from '@/components/misc/dropdown-item.vue'
|
||||
|
||||
import SubscriptionService from '@/services/subscription'
|
||||
import SubscriptionModel from '@/models/subscription'
|
||||
import type {ISubscription} from '@/modelTypes/ISubscription'
|
||||
|
||||
import {success} from '@/message'
|
||||
import type { IconProp } from '@fortawesome/fontawesome-svg-core'
|
||||
|
||||
const props = defineProps({
|
||||
entity: String as ISubscription['entity'],
|
||||
entityId: Number,
|
||||
isButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
modelValue: {
|
||||
type: Object as PropType<ISubscription>,
|
||||
default: null,
|
||||
},
|
||||
type: {
|
||||
type: String as PropType<'button' | 'dropdown' | 'null'>,
|
||||
default: 'button',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const subscriptionEntity = computed<string | null>(() => props.modelValue?.entity ?? null)
|
||||
|
||||
const subscriptionService = shallowRef(new SubscriptionService())
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
if (disabled.value) {
|
||||
if (props.entity === 'task' && subscriptionEntity.value === 'project') {
|
||||
return t('task.subscription.subscribedTaskThroughParentProject')
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
switch (props.entity) {
|
||||
case 'project':
|
||||
return props.modelValue !== null ?
|
||||
t('task.subscription.subscribedProject') :
|
||||
t('task.subscription.notSubscribedProject')
|
||||
case 'task':
|
||||
return props.modelValue !== null ?
|
||||
t('task.subscription.subscribedTask') :
|
||||
t('task.subscription.notSubscribedTask')
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
const buttonText = computed(() => props.modelValue ? t('task.subscription.unsubscribe') : t('task.subscription.subscribe'))
|
||||
const iconName = computed<IconProp>(() => props.modelValue ? ['far', 'bell-slash'] : 'bell')
|
||||
const disabled = computed(() => props.modelValue && subscriptionEntity.value !== props.entity)
|
||||
|
||||
function changeSubscription() {
|
||||
if (disabled.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (props.modelValue === null) {
|
||||
subscribe()
|
||||
} else {
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribe() {
|
||||
const subscription = new SubscriptionModel({
|
||||
entity: props.entity,
|
||||
entityId: props.entityId,
|
||||
})
|
||||
await subscriptionService.value.create(subscription)
|
||||
emit('update:modelValue', subscription)
|
||||
|
||||
let message = ''
|
||||
switch (props.entity) {
|
||||
case 'project':
|
||||
message = t('task.subscription.subscribeSuccessProject')
|
||||
break
|
||||
case 'task':
|
||||
message = t('task.subscription.subscribeSuccessTask')
|
||||
break
|
||||
}
|
||||
success({message})
|
||||
}
|
||||
|
||||
async function unsubscribe() {
|
||||
const subscription = new SubscriptionModel({
|
||||
entity: props.entity,
|
||||
entityId: props.entityId,
|
||||
})
|
||||
await subscriptionService.value.delete(subscription)
|
||||
emit('update:modelValue', null)
|
||||
|
||||
let message = ''
|
||||
switch (props.entity) {
|
||||
case 'project':
|
||||
message = t('task.subscription.unsubscribeSuccessProject')
|
||||
break
|
||||
case 'task':
|
||||
message = t('task.subscription.unsubscribeSuccessTask')
|
||||
break
|
||||
}
|
||||
success({message})
|
||||
}
|
||||
</script>
|
67
frontend/src/components/misc/user.vue
Normal file
@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div
|
||||
class="user"
|
||||
:class="{'is-inline': isInline}"
|
||||
>
|
||||
<img
|
||||
v-tooltip="displayName"
|
||||
:height="avatarSize"
|
||||
:src="getAvatarUrl(user, avatarSize)"
|
||||
:width="avatarSize"
|
||||
:alt="'Avatar of ' + displayName"
|
||||
class="avatar"
|
||||
>
|
||||
<span
|
||||
v-if="showUsername"
|
||||
class="username"
|
||||
>{{ displayName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, type PropType} from 'vue'
|
||||
|
||||
import {getAvatarUrl, getDisplayName} from '@/models/user'
|
||||
import type {IUser} from '@/modelTypes/IUser'
|
||||
|
||||
const props = defineProps({
|
||||
user: {
|
||||
type: Object as PropType<IUser>,
|
||||
required: true,
|
||||
},
|
||||
showUsername: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
avatarSize: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 50,
|
||||
},
|
||||
isInline: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const displayName = computed(() => getDisplayName(props.user))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user {
|
||||
display: flex;
|
||||
justify-items: center;
|
||||
|
||||
&.is-inline {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
border-radius: 100%;
|
||||
vertical-align: middle;
|
||||
margin-right: .5rem;
|
||||
}
|
||||
</style>
|
307
frontend/src/components/notifications/notifications.vue
Normal file
@ -0,0 +1,307 @@
|
||||
<template>
|
||||
<div class="notifications">
|
||||
<slot
|
||||
name="trigger"
|
||||
toggle-open="() => showNotifications = !showNotifications"
|
||||
:has-unread-notifications="unreadNotifications > 0"
|
||||
>
|
||||
<BaseButton
|
||||
class="trigger-button"
|
||||
@click.stop="showNotifications = !showNotifications"
|
||||
>
|
||||
<span
|
||||
v-if="unreadNotifications > 0"
|
||||
class="unread-indicator"
|
||||
/>
|
||||
<icon icon="bell" />
|
||||
</BaseButton>
|
||||
</slot>
|
||||
|
||||
<CustomTransition name="fade">
|
||||
<div
|
||||
v-if="showNotifications"
|
||||
ref="popup"
|
||||
class="notifications-list"
|
||||
>
|
||||
<span class="head">{{ $t('notification.title') }}</span>
|
||||
<div
|
||||
v-for="(n, index) in notifications"
|
||||
:key="n.id"
|
||||
class="single-notification"
|
||||
>
|
||||
<div
|
||||
class="read-indicator"
|
||||
:class="{'read': n.readAt !== null}"
|
||||
/>
|
||||
<User
|
||||
v-if="n.notification.doer"
|
||||
:user="n.notification.doer"
|
||||
:show-username="false"
|
||||
:avatar-size="16"
|
||||
/>
|
||||
<div class="detail">
|
||||
<div>
|
||||
<span
|
||||
v-if="n.notification.doer"
|
||||
class="has-text-weight-bold mr-1"
|
||||
>
|
||||
{{ getDisplayName(n.notification.doer) }}
|
||||
</span>
|
||||
<BaseButton
|
||||
class="has-text-left"
|
||||
@click="() => to(n, index)()"
|
||||
>
|
||||
{{ n.toText(userInfo) }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
<span
|
||||
v-tooltip="formatDateLong(n.created)"
|
||||
class="created"
|
||||
>
|
||||
{{ formatDateSince(n.created) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<XButton
|
||||
v-if="notifications.length > 0 && unreadNotifications > 0"
|
||||
variant="tertiary"
|
||||
class="mt-2 is-fullwidth"
|
||||
@click="markAllRead"
|
||||
>
|
||||
{{ $t('notification.markAllRead') }}
|
||||
</XButton>
|
||||
<p
|
||||
v-if="notifications.length === 0"
|
||||
class="nothing"
|
||||
>
|
||||
{{ $t('notification.none') }}<br>
|
||||
<span class="explainer">
|
||||
{{ $t('notification.explainer') }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</CustomTransition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, onMounted, onUnmounted, ref} from 'vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
|
||||
import NotificationService from '@/services/notification'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
import User from '@/components/misc/user.vue'
|
||||
import { NOTIFICATION_NAMES as names, type INotification} from '@/modelTypes/INotification'
|
||||
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
|
||||
import {formatDateLong, formatDateSince} from '@/helpers/time/formatDate'
|
||||
import {getDisplayName} from '@/models/user'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import XButton from '@/components/input/button.vue'
|
||||
import {success} from '@/message'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
const LOAD_NOTIFICATIONS_INTERVAL = 10000
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const {t} = useI18n()
|
||||
|
||||
const allNotifications = ref<INotification[]>([])
|
||||
const showNotifications = ref(false)
|
||||
const popup = ref(null)
|
||||
|
||||
const unreadNotifications = computed(() => {
|
||||
return notifications.value.filter(n => n.readAt === null).length
|
||||
})
|
||||
const notifications = computed(() => {
|
||||
return allNotifications.value ? allNotifications.value.filter(n => n.name !== '') : []
|
||||
})
|
||||
const userInfo = computed(() => authStore.info)
|
||||
|
||||
let interval: ReturnType<typeof setInterval>
|
||||
|
||||
onMounted(() => {
|
||||
loadNotifications()
|
||||
document.addEventListener('click', hidePopup)
|
||||
interval = setInterval(loadNotifications, LOAD_NOTIFICATIONS_INTERVAL)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', hidePopup)
|
||||
clearInterval(interval)
|
||||
})
|
||||
|
||||
async function loadNotifications() {
|
||||
// We're recreating the notification service here to make sure it uses the latest api user token
|
||||
const notificationService = new NotificationService()
|
||||
allNotifications.value = await notificationService.getAll()
|
||||
}
|
||||
|
||||
function hidePopup(e) {
|
||||
if (showNotifications.value) {
|
||||
closeWhenClickedOutside(e, popup.value, () => showNotifications.value = false)
|
||||
}
|
||||
}
|
||||
|
||||
function to(n, index) {
|
||||
const to = {
|
||||
name: '',
|
||||
params: {},
|
||||
}
|
||||
|
||||
switch (n.name) {
|
||||
case names.TASK_COMMENT:
|
||||
case names.TASK_ASSIGNED:
|
||||
case names.TASK_REMINDER:
|
||||
to.name = 'task.detail'
|
||||
to.params.id = n.notification.task.id
|
||||
break
|
||||
case names.TASK_DELETED:
|
||||
// Nothing
|
||||
break
|
||||
case names.PROJECT_CREATED:
|
||||
to.name = 'task.index'
|
||||
to.params.projectId = n.notification.project.id
|
||||
break
|
||||
case names.TEAM_MEMBER_ADDED:
|
||||
to.name = 'teams.edit'
|
||||
to.params.id = n.notification.team.id
|
||||
break
|
||||
}
|
||||
|
||||
return async () => {
|
||||
if (to.name !== '') {
|
||||
router.push(to)
|
||||
}
|
||||
|
||||
n.read = true
|
||||
const notificationService = new NotificationService()
|
||||
allNotifications.value[index] = await notificationService.update(n)
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
const notificationService = new NotificationService()
|
||||
await notificationService.markAllRead()
|
||||
success({message: t('notification.markAllReadSuccess')})
|
||||
|
||||
notifications.value.forEach(n => n.readAt = new Date())
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.notifications {
|
||||
display: flex;
|
||||
|
||||
.trigger-button {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.unread-indicator {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: .5rem;
|
||||
width: .75rem;
|
||||
height: .75rem;
|
||||
|
||||
background: var(--primary);
|
||||
border-radius: 100%;
|
||||
border: 2px solid var(--white);
|
||||
}
|
||||
|
||||
.notifications-list {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: calc(100% + 1rem);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
|
||||
background: var(--white);
|
||||
width: 350px;
|
||||
max-width: calc(100vw - 2rem);
|
||||
padding: .75rem .25rem;
|
||||
border-radius: $radius;
|
||||
box-shadow: var(--shadow-sm);
|
||||
font-size: .85rem;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
max-height: calc(100vh - 1rem - #{$navbar-height});
|
||||
}
|
||||
|
||||
.head {
|
||||
font-family: $vikunja-font;
|
||||
font-size: 1rem;
|
||||
padding: .5rem;
|
||||
}
|
||||
|
||||
.single-notification {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0;
|
||||
|
||||
transition: background-color $transition;
|
||||
|
||||
&:hover {
|
||||
background: var(--grey-100);
|
||||
border-radius: $radius;
|
||||
}
|
||||
|
||||
.read-indicator {
|
||||
width: .35rem;
|
||||
height: .35rem;
|
||||
background: var(--primary);
|
||||
border-radius: 100%;
|
||||
margin: 0 .5rem;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.read {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.user {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: auto;
|
||||
margin: 0 .5rem;
|
||||
|
||||
span {
|
||||
font-family: $family-sans-serif;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
img {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.created {
|
||||
color: var(--grey-400);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: .25rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--grey-800);
|
||||
}
|
||||
}
|
||||
|
||||
.nothing {
|
||||
text-align: center;
|
||||
padding: 1rem 0;
|
||||
color: var(--grey-500);
|
||||
|
||||
.explainer {
|
||||
font-size: .75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
217
frontend/src/components/project/ProjectWrapper.vue
Normal file
@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<div
|
||||
:class="{ 'is-loading': projectService.loading, 'is-archived': currentProject?.isArchived}"
|
||||
class="loader-container"
|
||||
>
|
||||
<h1 class="project-title-print">
|
||||
{{ getProjectTitle(currentProject) }}
|
||||
</h1>
|
||||
|
||||
<div class="switch-view-container d-print-none">
|
||||
<div class="switch-view">
|
||||
<BaseButton
|
||||
v-shortcut="'g l'"
|
||||
:title="$t('keyboardShortcuts.project.switchToListView')"
|
||||
class="switch-view-button"
|
||||
:class="{'is-active': viewName === 'project'}"
|
||||
:to="{ name: 'project.list', params: { projectId } }"
|
||||
>
|
||||
{{ $t('project.list.title') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-shortcut="'g g'"
|
||||
:title="$t('keyboardShortcuts.project.switchToGanttView')"
|
||||
class="switch-view-button"
|
||||
:class="{'is-active': viewName === 'gantt'}"
|
||||
:to="{ name: 'project.gantt', params: { projectId } }"
|
||||
>
|
||||
{{ $t('project.gantt.title') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-shortcut="'g t'"
|
||||
:title="$t('keyboardShortcuts.project.switchToTableView')"
|
||||
class="switch-view-button"
|
||||
:class="{'is-active': viewName === 'table'}"
|
||||
:to="{ name: 'project.table', params: { projectId } }"
|
||||
>
|
||||
{{ $t('project.table.title') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-shortcut="'g k'"
|
||||
:title="$t('keyboardShortcuts.project.switchToKanbanView')"
|
||||
class="switch-view-button"
|
||||
:class="{'is-active': viewName === 'kanban'}"
|
||||
:to="{ name: 'project.kanban', params: { projectId } }"
|
||||
>
|
||||
{{ $t('project.kanban.title') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<CustomTransition name="fade">
|
||||
<Message
|
||||
v-if="currentProject?.isArchived"
|
||||
variant="warning"
|
||||
class="mb-4"
|
||||
>
|
||||
{{ $t('project.archivedMessage') }}
|
||||
</Message>
|
||||
</CustomTransition>
|
||||
|
||||
<slot v-if="loadedProjectId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, watch} from 'vue'
|
||||
import {useRoute} from 'vue-router'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import Message from '@/components/misc/message.vue'
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
|
||||
import ProjectModel from '@/models/project'
|
||||
import ProjectService from '@/services/project'
|
||||
|
||||
import {getProjectTitle} from '@/helpers/getProjectTitle'
|
||||
import {saveProjectToHistory} from '@/modules/projectHistory'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
const props = defineProps({
|
||||
projectId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
viewName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const projectStore = useProjectStore()
|
||||
const projectService = ref(new ProjectService())
|
||||
const loadedProjectId = ref(0)
|
||||
|
||||
const currentProject = computed(() => {
|
||||
return typeof baseStore.currentProject === 'undefined' ? {
|
||||
id: 0,
|
||||
title: '',
|
||||
isArchived: false,
|
||||
maxRight: null,
|
||||
} : baseStore.currentProject
|
||||
})
|
||||
useTitle(() => currentProject.value?.id ? getProjectTitle(currentProject.value) : '')
|
||||
|
||||
// watchEffect would be called every time the prop would get a value assigned, even if that value was the same as before.
|
||||
// This resulted in loading and setting the project multiple times, even when navigating away from it.
|
||||
// This caused wired bugs where the project background would be set on the home page but only right after setting a new
|
||||
// project background and then navigating to home. It also highlighted the project in the menu and didn't allow changing any
|
||||
// of it, most likely due to the rights not being properly populated.
|
||||
watch(
|
||||
() => props.projectId,
|
||||
// loadProject
|
||||
async (projectIdToLoad: number) => {
|
||||
const projectData = {id: projectIdToLoad}
|
||||
saveProjectToHistory(projectData)
|
||||
|
||||
// Don't load the project if we either already loaded it or aren't dealing with a project at all currently and
|
||||
// the currently loaded project has the right set.
|
||||
if (
|
||||
(
|
||||
projectIdToLoad === loadedProjectId.value ||
|
||||
typeof projectIdToLoad === 'undefined' ||
|
||||
projectIdToLoad === currentProject.value?.id
|
||||
)
|
||||
&& typeof currentProject.value !== 'undefined' && currentProject.value.maxRight !== null
|
||||
) {
|
||||
loadedProjectId.value = props.projectId
|
||||
return
|
||||
}
|
||||
|
||||
console.debug(`Loading project, props.viewName = ${props.viewName}, $route.params =`, route.params, `, loadedProjectId = ${loadedProjectId.value}, currentProject = `, currentProject.value)
|
||||
|
||||
// Set the current project to the one we're about to load so that the title is already shown at the top
|
||||
loadedProjectId.value = 0
|
||||
const projectFromStore = projectStore.projects[projectData.id]
|
||||
if (projectFromStore) {
|
||||
baseStore.handleSetCurrentProject({project: projectFromStore})
|
||||
}
|
||||
|
||||
// We create an extra project object instead of creating it in project.value because that would trigger a ui update which would result in bad ux.
|
||||
const project = new ProjectModel(projectData)
|
||||
try {
|
||||
const loadedProject = await projectService.value.get(project)
|
||||
baseStore.handleSetCurrentProject({project: loadedProject})
|
||||
} finally {
|
||||
loadedProjectId.value = props.projectId
|
||||
}
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.switch-view-container {
|
||||
@media screen and (max-width: $tablet) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.switch-view {
|
||||
background: var(--white);
|
||||
display: inline-flex;
|
||||
border-radius: $radius;
|
||||
font-size: .75rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
height: $switch-view-height;
|
||||
margin: 0 auto 1rem;
|
||||
padding: .5rem;
|
||||
}
|
||||
|
||||
.switch-view-button {
|
||||
padding: .25rem .5rem;
|
||||
display: block;
|
||||
border-radius: $radius;
|
||||
transition: all 100ms;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: .5rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--switch-view-color);
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
color: var(--switch-view-color);
|
||||
background: var(--primary);
|
||||
font-weight: bold;
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: this should be in notification and set via a prop
|
||||
.is-archived .notification.is-warning {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.project-title-print {
|
||||
display: none;
|
||||
font-size: 1.75rem;
|
||||
text-align: center;
|
||||
margin-bottom: .5rem;
|
||||
|
||||
@media print {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
198
frontend/src/components/project/partials/ProjectCard.vue
Normal file
@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<div
|
||||
class="project-card"
|
||||
:class="{
|
||||
'has-light-text': background !== null,
|
||||
'has-background': blurHashUrl !== '' || background !== null
|
||||
}"
|
||||
:style="{
|
||||
'border-left': project.hexColor ? `0.25rem solid ${project.hexColor}` : undefined,
|
||||
'background-image': blurHashUrl !== '' ? `url(${blurHashUrl})` : undefined,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="project-background background-fade-in"
|
||||
:class="{'is-visible': background}"
|
||||
:style="{'background-image': background !== null ? `url(${background})` : undefined}"
|
||||
/>
|
||||
<span
|
||||
v-if="project.isArchived"
|
||||
class="is-archived"
|
||||
>{{ $t('project.archived') }}</span>
|
||||
|
||||
<div
|
||||
class="project-title"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
v-if="project.id < -1"
|
||||
class="saved-filter-icon icon"
|
||||
>
|
||||
<icon icon="filter" />
|
||||
</span>
|
||||
{{ project.title }}
|
||||
</div>
|
||||
<BaseButton
|
||||
class="project-button"
|
||||
:aria-label="project.title"
|
||||
:title="project.description"
|
||||
:to="{
|
||||
name: 'project.index',
|
||||
params: { projectId: project.id}
|
||||
}"
|
||||
/>
|
||||
<BaseButton
|
||||
v-if="!project.isArchived && project.id > -1"
|
||||
class="favorite"
|
||||
:class="{'is-favorite': project.isFavorite}"
|
||||
@click.prevent.stop="projectStore.toggleProjectFavorite(project)"
|
||||
>
|
||||
<icon :icon="project.isFavorite ? 'star' : ['far', 'star']" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
import {useProjectBackground} from './useProjectBackground'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
const {
|
||||
project,
|
||||
} = defineProps<{
|
||||
project: IProject,
|
||||
}>()
|
||||
|
||||
const {background, blurHashUrl} = useProjectBackground(project)
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.project-card {
|
||||
--project-card-padding: 1rem;
|
||||
background: var(--white);
|
||||
padding: var(--project-card-padding);
|
||||
border-radius: $radius;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow $transition;
|
||||
position: relative;
|
||||
overflow: hidden; // hide background
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
&:active,
|
||||
&:focus {
|
||||
box-shadow: var(--shadow-xs) !important;
|
||||
}
|
||||
|
||||
> * {
|
||||
// so the elements are on top of the background
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.has-background,
|
||||
.project-background {
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.project-background,
|
||||
.project-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.is-archived {
|
||||
font-size: .75rem;
|
||||
float: left;
|
||||
}
|
||||
|
||||
.project-title {
|
||||
align-self: flex-end;
|
||||
font-family: $vikunja-font;
|
||||
font-weight: 400;
|
||||
font-size: 1.5rem;
|
||||
line-height: var(--title-line-height);
|
||||
color: var(--text);
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
max-height: calc(100% - (var(--project-card-padding) + 1rem)); // padding & height of the "is archived" badge
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-word;
|
||||
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.has-light-text .project-title {
|
||||
color: var(--grey-100);
|
||||
}
|
||||
|
||||
.has-background .project-title {
|
||||
text-shadow:
|
||||
0 0 10px var(--black),
|
||||
1px 1px 5px var(--grey-700),
|
||||
-1px -1px 5px var(--grey-700);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.favorite {
|
||||
position: absolute;
|
||||
top: var(--project-card-padding);
|
||||
right: var(--project-card-padding);
|
||||
transition: opacity $transition, color $transition;
|
||||
opacity: 1;
|
||||
|
||||
&:hover {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
&.is-favorite {
|
||||
display: inline-block;
|
||||
opacity: 1;
|
||||
color: var(--warning);
|
||||
}
|
||||
}
|
||||
|
||||
@media(hover: hover) and (pointer: fine) {
|
||||
.project-card .favorite {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.project-card:hover .favorite {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.background-fade-in {
|
||||
opacity: 0;
|
||||
transition: opacity $transition;
|
||||
transition-delay: $transition-duration * 2; // To fake an appearing background
|
||||
|
||||
&.is-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.saved-filter-icon {
|
||||
color: var(--grey-300);
|
||||
font-size: .75em;
|
||||
}
|
||||
</style>
|
73
frontend/src/components/project/partials/ProjectCardGrid.vue
Normal file
@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<ul class="project-grid">
|
||||
<li
|
||||
v-for="(item, index) in filteredProjects"
|
||||
:key="`project_${item.id}_${index}`"
|
||||
class="project-grid-item"
|
||||
>
|
||||
<ProjectCard :project="item" />
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, type PropType} from 'vue'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
import ProjectCard from './ProjectCard.vue'
|
||||
|
||||
const props = defineProps({
|
||||
projects: {
|
||||
type: Array as PropType<IProject[]>,
|
||||
default: () => [],
|
||||
},
|
||||
showArchived: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
itemLimit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const filteredProjects = computed(() => {
|
||||
return props.showArchived
|
||||
? props.projects
|
||||
: props.projects.filter(l => !l.isArchived)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.project-grid {
|
||||
--project-grid-item-height: 150px;
|
||||
--project-grid-gap: 1rem;
|
||||
margin: 0; // reset li
|
||||
list-style-type: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--project-grid-columns), 1fr);
|
||||
grid-auto-rows: var(--project-grid-item-height);
|
||||
gap: var(--project-grid-gap);
|
||||
|
||||
@media screen and (min-width: $mobile) {
|
||||
--project-grid-columns: 1;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $mobile) and (max-width: $tablet) {
|
||||
--project-grid-columns: 2;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $tablet) and (max-width: $widescreen) {
|
||||
--project-grid-columns: 3;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $widescreen) {
|
||||
--project-grid-columns: 5;
|
||||
}
|
||||
}
|
||||
|
||||
.project-grid-item {
|
||||
display: grid;
|
||||
margin-top: 0; // remove padding coming form .content li + li
|
||||
}
|
||||
</style>
|
99
frontend/src/components/project/partials/filter-popup.vue
Normal file
@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<x-button
|
||||
v-if="hasFilters"
|
||||
variant="secondary"
|
||||
@click="clearFilters"
|
||||
>
|
||||
{{ $t('filters.clear') }}
|
||||
</x-button>
|
||||
<x-button
|
||||
variant="secondary"
|
||||
icon="filter"
|
||||
@click="() => modalOpen = true"
|
||||
>
|
||||
{{ $t('filters.title') }}
|
||||
</x-button>
|
||||
<modal
|
||||
:enabled="modalOpen"
|
||||
transition-name="fade"
|
||||
:overflow="true"
|
||||
variant="hint-modal"
|
||||
@close="() => modalOpen = false"
|
||||
>
|
||||
<Filters
|
||||
ref="filters"
|
||||
v-model="value"
|
||||
:has-title="true"
|
||||
class="filter-popup"
|
||||
/>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, watch} from 'vue'
|
||||
|
||||
import Filters from '@/components/project/partials/filters.vue'
|
||||
|
||||
import {getDefaultParams} from '@/composables/useTaskList'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const value = computed({
|
||||
get() {
|
||||
return props.modelValue
|
||||
},
|
||||
set(value) {
|
||||
if(props.modelValue === value) {
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(modelValue) => {
|
||||
value.value = modelValue
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
const hasFilters = computed(() => {
|
||||
// this.value also contains the page parameter which we don't want to include in filters
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const {filter_by, filter_value, filter_comparator, filter_concat, s} = value.value
|
||||
const def = {...getDefaultParams()}
|
||||
|
||||
const params = {filter_by, filter_value, filter_comparator, filter_concat, s}
|
||||
const defaultParams = {
|
||||
filter_by: def.filter_by,
|
||||
filter_value: def.filter_value,
|
||||
filter_comparator: def.filter_comparator,
|
||||
filter_concat: def.filter_concat,
|
||||
s: s ? def.s : undefined,
|
||||
}
|
||||
|
||||
return JSON.stringify(params) !== JSON.stringify(defaultParams)
|
||||
})
|
||||
|
||||
const modalOpen = ref(false)
|
||||
|
||||
function clearFilters() {
|
||||
value.value = {...getDefaultParams()}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.filter-popup {
|
||||
margin: 0;
|
||||
|
||||
&.is-open {
|
||||
margin: 2rem 0 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
618
frontend/src/components/project/partials/filters.vue
Normal file
@ -0,0 +1,618 @@
|
||||
<template>
|
||||
<card
|
||||
class="filters has-overflow"
|
||||
:title="hasTitle ? $t('filters.title') : ''"
|
||||
>
|
||||
<div class="field is-flex is-flex-direction-column">
|
||||
<Fancycheckbox
|
||||
v-model="params.filter_include_nulls"
|
||||
@update:modelValue="change()"
|
||||
>
|
||||
{{ $t('filters.attributes.includeNulls') }}
|
||||
</Fancycheckbox>
|
||||
<Fancycheckbox
|
||||
v-model="filters.requireAllFilters"
|
||||
@update:modelValue="setFilterConcat()"
|
||||
>
|
||||
{{ $t('filters.attributes.requireAll') }}
|
||||
</Fancycheckbox>
|
||||
<Fancycheckbox
|
||||
v-model="filters.done"
|
||||
@update:modelValue="setDoneFilter"
|
||||
>
|
||||
{{ $t('filters.attributes.showDoneTasks') }}
|
||||
</Fancycheckbox>
|
||||
<Fancycheckbox
|
||||
v-if="!['project.kanban', 'project.table'].includes($route.name as string)"
|
||||
v-model="sortAlphabetically"
|
||||
@update:modelValue="change()"
|
||||
>
|
||||
{{ $t('filters.attributes.sortAlphabetically') }}
|
||||
</Fancycheckbox>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('misc.search') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
v-model="params.s"
|
||||
class="input"
|
||||
:placeholder="$t('misc.search')"
|
||||
@blur="change()"
|
||||
@keyup.enter="change()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.attributes.priority') }}</label>
|
||||
<div class="control single-value-control">
|
||||
<PrioritySelect
|
||||
v-model.number="filters.priority"
|
||||
:disabled="!filters.usePriority || undefined"
|
||||
@update:modelValue="setPriority"
|
||||
/>
|
||||
<Fancycheckbox
|
||||
v-model="filters.usePriority"
|
||||
@update:modelValue="setPriority"
|
||||
>
|
||||
{{ $t('filters.attributes.enablePriority') }}
|
||||
</Fancycheckbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.attributes.percentDone') }}</label>
|
||||
<div class="control single-value-control">
|
||||
<PercentDoneSelect
|
||||
v-model.number="filters.percentDone"
|
||||
:disabled="!filters.usePercentDone || undefined"
|
||||
@update:modelValue="setPercentDoneFilter"
|
||||
/>
|
||||
<Fancycheckbox
|
||||
v-model="filters.usePercentDone"
|
||||
@update:modelValue="setPercentDoneFilter"
|
||||
>
|
||||
{{ $t('filters.attributes.enablePercentDone') }}
|
||||
</Fancycheckbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.attributes.dueDate') }}</label>
|
||||
<div class="control">
|
||||
<DatepickerWithRange
|
||||
v-model="filters.dueDate"
|
||||
@update:modelValue="values => setDateFilter('due_date', values)"
|
||||
>
|
||||
<template #trigger="{toggle, buttonText}">
|
||||
<x-button
|
||||
variant="secondary"
|
||||
:shadow="false"
|
||||
class="mb-2"
|
||||
@click.prevent.stop="toggle()"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</x-button>
|
||||
</template>
|
||||
</DatepickerWithRange>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.attributes.startDate') }}</label>
|
||||
<div class="control">
|
||||
<DatepickerWithRange
|
||||
v-model="filters.startDate"
|
||||
@update:modelValue="values => setDateFilter('start_date', values)"
|
||||
>
|
||||
<template #trigger="{toggle, buttonText}">
|
||||
<x-button
|
||||
variant="secondary"
|
||||
:shadow="false"
|
||||
class="mb-2"
|
||||
@click.prevent.stop="toggle()"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</x-button>
|
||||
</template>
|
||||
</DatepickerWithRange>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.attributes.endDate') }}</label>
|
||||
<div class="control">
|
||||
<DatepickerWithRange
|
||||
v-model="filters.endDate"
|
||||
@update:modelValue="values => setDateFilter('end_date', values)"
|
||||
>
|
||||
<template #trigger="{toggle, buttonText}">
|
||||
<x-button
|
||||
variant="secondary"
|
||||
:shadow="false"
|
||||
class="mb-2"
|
||||
@click.prevent.stop="toggle()"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</x-button>
|
||||
</template>
|
||||
</DatepickerWithRange>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.attributes.reminders') }}</label>
|
||||
<div class="control">
|
||||
<DatepickerWithRange
|
||||
v-model="filters.reminders"
|
||||
@update:modelValue="values => setDateFilter('reminders', values)"
|
||||
>
|
||||
<template #trigger="{toggle, buttonText}">
|
||||
<x-button
|
||||
variant="secondary"
|
||||
:shadow="false"
|
||||
class="mb-2"
|
||||
@click.prevent.stop="toggle()"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</x-button>
|
||||
</template>
|
||||
</DatepickerWithRange>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.attributes.assignees') }}</label>
|
||||
<div class="control">
|
||||
<SelectUser
|
||||
v-model="entities.users"
|
||||
@select="changeMultiselectFilter('users', 'assignees')"
|
||||
@remove="changeMultiselectFilter('users', 'assignees')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.attributes.labels') }}</label>
|
||||
<div class="control labels-list">
|
||||
<EditLabels
|
||||
v-model="entities.labels"
|
||||
:creatable="false"
|
||||
@update:modelValue="changeLabelFilter"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template
|
||||
v-if="['filters.create', 'project.edit', 'filter.settings.edit'].includes($route.name as string)"
|
||||
>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('project.projects') }}</label>
|
||||
<div class="control">
|
||||
<SelectProject
|
||||
v-model="entities.projects"
|
||||
:project-filter="p => p.id > 0"
|
||||
@select="changeMultiselectFilter('projects', 'project_id')"
|
||||
@remove="changeMultiselectFilter('projects', 'project_id')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export const ALPHABETICAL_SORT = 'title'
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, nextTick, onMounted, reactive, ref, shallowReactive, toRefs} from 'vue'
|
||||
import {camelCase} from 'camel-case'
|
||||
import {watchDebounced} from '@vueuse/core'
|
||||
|
||||
import type {ILabel} from '@/modelTypes/ILabel'
|
||||
import type {IUser} from '@/modelTypes/IUser'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
import {useLabelStore} from '@/stores/labels'
|
||||
|
||||
import DatepickerWithRange from '@/components/date/datepickerWithRange.vue'
|
||||
import PrioritySelect from '@/components/tasks/partials/prioritySelect.vue'
|
||||
import PercentDoneSelect from '@/components/tasks/partials/percentDoneSelect.vue'
|
||||
import EditLabels from '@/components/tasks/partials/editLabels.vue'
|
||||
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||
import SelectUser from '@/components/input/SelectUser.vue'
|
||||
import SelectProject from '@/components/input/SelectProject.vue'
|
||||
|
||||
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
|
||||
import {dateIsValid, formatISO} from '@/helpers/time/formatDate'
|
||||
import {objectToSnakeCase} from '@/helpers/case'
|
||||
|
||||
import UserService from '@/services/user'
|
||||
import ProjectService from '@/services/project'
|
||||
|
||||
// FIXME: do not use this here for now. instead create new version from DEFAULT_PARAMS
|
||||
import {getDefaultParams} from '@/composables/useTaskList'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
required: true,
|
||||
},
|
||||
hasTitle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
// FIXME: merge with DEFAULT_PARAMS in taskProject.js
|
||||
const DEFAULT_PARAMS = {
|
||||
sort_by: [],
|
||||
order_by: [],
|
||||
filter_by: [],
|
||||
filter_value: [],
|
||||
filter_comparator: [],
|
||||
filter_include_nulls: true,
|
||||
filter_concat: 'or',
|
||||
s: '',
|
||||
} as const
|
||||
|
||||
const DEFAULT_FILTERS = {
|
||||
done: false,
|
||||
dueDate: '',
|
||||
requireAllFilters: false,
|
||||
priority: 0,
|
||||
usePriority: false,
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
percentDone: 0,
|
||||
usePercentDone: false,
|
||||
reminders: '',
|
||||
assignees: '',
|
||||
labels: '',
|
||||
project_id: '',
|
||||
} as const
|
||||
|
||||
const {modelValue} = toRefs(props)
|
||||
|
||||
const labelStore = useLabelStore()
|
||||
|
||||
const params = ref({...DEFAULT_PARAMS})
|
||||
const filters = ref({...DEFAULT_FILTERS})
|
||||
|
||||
const services = {
|
||||
users: shallowReactive(new UserService()),
|
||||
projects: shallowReactive(new ProjectService()),
|
||||
}
|
||||
|
||||
interface Entities {
|
||||
users: IUser[]
|
||||
labels: ILabel[]
|
||||
projects: IProject[]
|
||||
}
|
||||
|
||||
type EntityType = 'users' | 'labels' | 'projects'
|
||||
|
||||
const entities: Entities = reactive({
|
||||
users: [],
|
||||
labels: [],
|
||||
projects: [],
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
filters.value.requireAllFilters = params.value.filter_concat === 'and'
|
||||
})
|
||||
|
||||
// Using watchDebounced to prevent the filter re-triggering itself.
|
||||
// FIXME: Only here until this whole component changes a lot with the new filter syntax.
|
||||
watchDebounced(
|
||||
modelValue,
|
||||
(value) => {
|
||||
// FIXME: filters should only be converted to snake case in the last moment
|
||||
params.value = objectToSnakeCase(value)
|
||||
prepareFilters()
|
||||
},
|
||||
{immediate: true, debounce: 500, maxWait: 1000},
|
||||
)
|
||||
|
||||
const sortAlphabetically = computed({
|
||||
get() {
|
||||
return params.value?.sort_by?.find(sortBy => sortBy === ALPHABETICAL_SORT) !== undefined
|
||||
},
|
||||
set(sortAlphabetically) {
|
||||
params.value.sort_by = sortAlphabetically
|
||||
? [ALPHABETICAL_SORT]
|
||||
: getDefaultParams().sort_by
|
||||
|
||||
change()
|
||||
},
|
||||
})
|
||||
|
||||
function change() {
|
||||
const newParams = {...params.value}
|
||||
newParams.filter_value = newParams.filter_value.map(v => v instanceof Date ? v.toISOString() : v)
|
||||
emit('update:modelValue', newParams)
|
||||
}
|
||||
|
||||
function prepareFilters() {
|
||||
prepareDone()
|
||||
prepareDate('due_date', 'dueDate')
|
||||
prepareDate('start_date', 'startDate')
|
||||
prepareDate('end_date', 'endDate')
|
||||
prepareSingleValue('priority', 'priority', 'usePriority', true)
|
||||
prepareSingleValue('percent_done', 'percentDone', 'usePercentDone', true)
|
||||
prepareDate('reminders', 'reminders')
|
||||
prepareRelatedObjectFilter('users', 'assignees')
|
||||
prepareProjectsFilter()
|
||||
|
||||
prepareSingleValue('labels')
|
||||
|
||||
const newLabels = typeof filters.value.labels === 'string'
|
||||
? filters.value.labels
|
||||
: ''
|
||||
const labelIds = newLabels.split(',').map(i => parseInt(i))
|
||||
|
||||
entities.labels = labelStore.getLabelsByIds(labelIds)
|
||||
}
|
||||
|
||||
function removePropertyFromFilter(filterName) {
|
||||
// Because of the way arrays work, we can only ever remove one element at once.
|
||||
// To remove multiple filter elements of the same name this function has to be called multiple times.
|
||||
for (const i in params.value.filter_by) {
|
||||
if (params.value.filter_by[i] === filterName) {
|
||||
params.value.filter_by.splice(i, 1)
|
||||
params.value.filter_comparator.splice(i, 1)
|
||||
params.value.filter_value.splice(i, 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setDateFilter(filterName, {dateFrom, dateTo}) {
|
||||
dateFrom = parseDateOrString(dateFrom, null)
|
||||
dateTo = parseDateOrString(dateTo, null)
|
||||
|
||||
// Only filter if we have a date
|
||||
if (dateFrom !== null && dateTo !== null) {
|
||||
|
||||
// Check if we already have values in params and only update them if we do
|
||||
let foundStart = false
|
||||
let foundEnd = false
|
||||
params.value.filter_by.forEach((f, i) => {
|
||||
if (f === filterName && params.value.filter_comparator[i] === 'greater_equals') {
|
||||
foundStart = true
|
||||
params.value.filter_value[i] = dateFrom
|
||||
}
|
||||
if (f === filterName && params.value.filter_comparator[i] === 'less_equals') {
|
||||
foundEnd = true
|
||||
params.value.filter_value[i] = dateTo
|
||||
}
|
||||
})
|
||||
|
||||
if (!foundStart) {
|
||||
params.value.filter_by.push(filterName)
|
||||
params.value.filter_comparator.push('greater_equals')
|
||||
params.value.filter_value.push(dateFrom)
|
||||
}
|
||||
if (!foundEnd) {
|
||||
params.value.filter_by.push(filterName)
|
||||
params.value.filter_comparator.push('less_equals')
|
||||
params.value.filter_value.push(dateTo)
|
||||
}
|
||||
|
||||
filters.value[camelCase(filterName)] = {
|
||||
// Passing the dates as string values avoids an endless loop between values changing
|
||||
// in the datepicker (bubbling up to here) and changing here and bubbling down to the
|
||||
// datepicker (because there's a new date instance every time this function gets called).
|
||||
// See https://kolaente.dev/vikunja/frontend/issues/2384
|
||||
dateFrom: dateIsValid(dateFrom) ? formatISO(dateFrom) : dateFrom,
|
||||
dateTo: dateIsValid(dateTo) ? formatISO(dateTo) : dateTo,
|
||||
}
|
||||
change()
|
||||
return
|
||||
}
|
||||
|
||||
removePropertyFromFilter(filterName)
|
||||
removePropertyFromFilter(filterName)
|
||||
change()
|
||||
}
|
||||
|
||||
function prepareDate(filterName: string, variableName: 'dueDate' | 'startDate' | 'endDate' | 'reminders') {
|
||||
if (typeof params.value.filter_by === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
let foundDateStart: boolean | string = false
|
||||
let foundDateEnd: boolean | string = false
|
||||
for (const i in params.value.filter_by) {
|
||||
if (params.value.filter_by[i] === filterName && params.value.filter_comparator[i] === 'greater_equals') {
|
||||
foundDateStart = i
|
||||
}
|
||||
if (params.value.filter_by[i] === filterName && params.value.filter_comparator[i] === 'less_equals') {
|
||||
foundDateEnd = i
|
||||
}
|
||||
|
||||
if (foundDateStart !== false && foundDateEnd !== false) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (foundDateStart !== false && foundDateEnd !== false) {
|
||||
const startDate = new Date(params.value.filter_value[foundDateStart])
|
||||
const endDate = new Date(params.value.filter_value[foundDateEnd])
|
||||
filters.value[variableName] = {
|
||||
dateFrom: !isNaN(startDate)
|
||||
? `${startDate.getUTCFullYear()}-${startDate.getUTCMonth() + 1}-${startDate.getUTCDate()}`
|
||||
: params.value.filter_value[foundDateStart],
|
||||
dateTo: !isNaN(endDate)
|
||||
? `${endDate.getUTCFullYear()}-${endDate.getUTCMonth() + 1}-${endDate.getUTCDate()}`
|
||||
: params.value.filter_value[foundDateEnd],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setSingleValueFilter(filterName, variableName, useVariableName = '', comparator = 'equals') {
|
||||
if (useVariableName !== '' && !filters.value[useVariableName]) {
|
||||
removePropertyFromFilter(filterName)
|
||||
return
|
||||
}
|
||||
|
||||
let found = false
|
||||
params.value.filter_by.forEach((f, i) => {
|
||||
if (f === filterName) {
|
||||
found = true
|
||||
params.value.filter_value[i] = filters.value[variableName]
|
||||
}
|
||||
})
|
||||
|
||||
if (!found) {
|
||||
params.value.filter_by.push(filterName)
|
||||
params.value.filter_comparator.push(comparator)
|
||||
params.value.filter_value.push(filters.value[variableName])
|
||||
}
|
||||
|
||||
change()
|
||||
}
|
||||
|
||||
function prepareSingleValue(
|
||||
/** The filter name in the api. */
|
||||
filterName,
|
||||
/** The name of the variable in filters ref. */
|
||||
variableName = null,
|
||||
/** The name of the variable of the "Use this filter" variable. Will only be set if the parameter is not null. */
|
||||
useVariableName = null,
|
||||
/** Toggles if the value should be parsed as a number. */
|
||||
isNumber = false,
|
||||
) {
|
||||
if (variableName === null) {
|
||||
variableName = filterName
|
||||
}
|
||||
|
||||
let found = false
|
||||
for (const i in params.value.filter_by) {
|
||||
if (params.value.filter_by[i] === filterName) {
|
||||
found = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (found === false && useVariableName !== null) {
|
||||
filters.value[useVariableName] = false
|
||||
return
|
||||
}
|
||||
|
||||
if (isNumber) {
|
||||
filters.value[variableName] = Number(params.value.filter_value[found])
|
||||
} else {
|
||||
filters.value[variableName] = params.value.filter_value[found]
|
||||
}
|
||||
|
||||
if (useVariableName !== null) {
|
||||
filters.value[useVariableName] = true
|
||||
}
|
||||
}
|
||||
|
||||
function prepareDone() {
|
||||
// Set filters.done based on params
|
||||
if (typeof params.value.filter_by === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
filters.value.done = params.value.filter_by.some((f) => f === 'done') === false
|
||||
}
|
||||
|
||||
async function prepareRelatedObjectFilter(kind: EntityType, filterName = null, servicePrefix: Omit<EntityType, 'labels'> | null = null) {
|
||||
if (filterName === null) {
|
||||
filterName = kind
|
||||
}
|
||||
|
||||
if (servicePrefix === null) {
|
||||
servicePrefix = kind
|
||||
}
|
||||
|
||||
prepareSingleValue(filterName)
|
||||
if (typeof filters.value[filterName] === 'undefined' || filters.value[filterName] === '') {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't load things if we already have something loaded.
|
||||
// This is not the most ideal solution because it prevents a re-population when filters are changed
|
||||
// from the outside. It is still fine because we're not changing them from the outside, other than
|
||||
// loading them initially.
|
||||
if (entities[kind].length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
entities[kind] = await services[servicePrefix].getAll({}, {s: filters.value[filterName]})
|
||||
}
|
||||
|
||||
async function prepareProjectsFilter() {
|
||||
await prepareRelatedObjectFilter('projects', 'project_id')
|
||||
entities.projects = entities.projects.filter(p => p.id > 0)
|
||||
}
|
||||
|
||||
function setDoneFilter() {
|
||||
if (filters.value.done) {
|
||||
removePropertyFromFilter('done')
|
||||
} else {
|
||||
params.value.filter_by.push('done')
|
||||
params.value.filter_comparator.push('equals')
|
||||
params.value.filter_value.push('false')
|
||||
}
|
||||
change()
|
||||
}
|
||||
|
||||
function setFilterConcat() {
|
||||
if (filters.value.requireAllFilters) {
|
||||
params.value.filter_concat = 'and'
|
||||
} else {
|
||||
params.value.filter_concat = 'or'
|
||||
}
|
||||
change()
|
||||
}
|
||||
|
||||
function setPriority() {
|
||||
setSingleValueFilter('priority', 'priority', 'usePriority')
|
||||
}
|
||||
|
||||
function setPercentDoneFilter() {
|
||||
setSingleValueFilter('percent_done', 'percentDone', 'usePercentDone')
|
||||
}
|
||||
|
||||
async function changeMultiselectFilter(kind: EntityType, filterName) {
|
||||
await nextTick()
|
||||
|
||||
if (entities[kind].length === 0) {
|
||||
removePropertyFromFilter(filterName)
|
||||
change()
|
||||
return
|
||||
}
|
||||
|
||||
const ids = entities[kind].map(u => kind === 'users' ? u.username : u.id)
|
||||
|
||||
filters.value[filterName] = ids.join(',')
|
||||
setSingleValueFilter(filterName, filterName, '', 'in')
|
||||
}
|
||||
|
||||
function changeLabelFilter() {
|
||||
if (entities.labels.length === 0) {
|
||||
removePropertyFromFilter('labels')
|
||||
change()
|
||||
return
|
||||
}
|
||||
|
||||
const labelIDs = entities.labels.map(u => u.id)
|
||||
filters.value.labels = labelIDs.join(',')
|
||||
setSingleValueFilter('labels', 'labels', '', 'in')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.single-value-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.fancycheckbox {
|
||||
margin-left: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.datepicker-with-range-container .popup) {
|
||||
right: 0;
|
||||
}
|
||||
</style>
|
@ -0,0 +1,55 @@
|
||||
import {ref, watch, type ShallowReactive} from 'vue'
|
||||
import ProjectService from '@/services/project'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
|
||||
|
||||
export function useProjectBackground(project: ShallowReactive<IProject>) {
|
||||
const background = ref<string | null>(null)
|
||||
const backgroundLoading = ref(false)
|
||||
const blurHashUrl = ref('')
|
||||
|
||||
watch(
|
||||
() => [project.id, project.backgroundBlurHash] as [IProject['id'], IProject['backgroundBlurHash']],
|
||||
async ([projectId, blurHash], oldValue) => {
|
||||
if (
|
||||
project === null ||
|
||||
!project.backgroundInformation ||
|
||||
backgroundLoading.value
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const [oldProjectId, oldBlurHash] = oldValue || []
|
||||
if (
|
||||
oldValue !== undefined &&
|
||||
projectId === oldProjectId && blurHash === oldBlurHash
|
||||
) {
|
||||
// project hasn't changed
|
||||
return
|
||||
}
|
||||
|
||||
backgroundLoading.value = true
|
||||
|
||||
try {
|
||||
const blurHashPromise = getBlobFromBlurHash(blurHash).then((blurHash) => {
|
||||
blurHashUrl.value = blurHash ? window.URL.createObjectURL(blurHash) : ''
|
||||
})
|
||||
|
||||
const projectService = new ProjectService()
|
||||
const backgroundPromise = projectService.background(project).then((result) => {
|
||||
background.value = result
|
||||
})
|
||||
await Promise.all([blurHashPromise, backgroundPromise])
|
||||
} finally {
|
||||
backgroundLoading.value = false
|
||||
}
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
return {
|
||||
background,
|
||||
blurHashUrl,
|
||||
backgroundLoading,
|
||||
}
|
||||
}
|
149
frontend/src/components/project/project-settings-dropdown.vue
Normal file
@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<Dropdown>
|
||||
<template #trigger="triggerProps">
|
||||
<slot
|
||||
name="trigger"
|
||||
v-bind="triggerProps"
|
||||
>
|
||||
<BaseButton
|
||||
class="dropdown-trigger"
|
||||
@click="triggerProps.toggleOpen"
|
||||
>
|
||||
<icon
|
||||
icon="ellipsis-h"
|
||||
class="icon"
|
||||
/>
|
||||
</BaseButton>
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<template v-if="isSavedFilter(project)">
|
||||
<DropdownItem
|
||||
:to="{ name: 'filter.settings.edit', params: { projectId: project.id } }"
|
||||
icon="pen"
|
||||
>
|
||||
{{ $t('menu.edit') }}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
:to="{ name: 'filter.settings.delete', params: { projectId: project.id } }"
|
||||
icon="trash-alt"
|
||||
>
|
||||
{{ $t('misc.delete') }}
|
||||
</DropdownItem>
|
||||
</template>
|
||||
|
||||
<template v-else-if="project.isArchived">
|
||||
<DropdownItem
|
||||
:to="{ name: 'project.settings.archive', params: { projectId: project.id } }"
|
||||
icon="archive"
|
||||
>
|
||||
{{ $t('menu.unarchive') }}
|
||||
</DropdownItem>
|
||||
</template>
|
||||
<template v-else>
|
||||
<DropdownItem
|
||||
:to="{ name: 'project.settings.edit', params: { projectId: project.id } }"
|
||||
icon="pen"
|
||||
>
|
||||
{{ $t('menu.edit') }}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
v-if="backgroundsEnabled"
|
||||
:to="{ name: 'project.settings.background', params: { projectId: project.id } }"
|
||||
icon="image"
|
||||
>
|
||||
{{ $t('menu.setBackground') }}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
:to="{ name: 'project.settings.share', params: { projectId: project.id } }"
|
||||
icon="share-alt"
|
||||
>
|
||||
{{ $t('menu.share') }}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
:to="{ name: 'project.settings.duplicate', params: { projectId: project.id } }"
|
||||
icon="paste"
|
||||
>
|
||||
{{ $t('menu.duplicate') }}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
:to="{ name: 'project.settings.archive', params: { projectId: project.id } }"
|
||||
icon="archive"
|
||||
>
|
||||
{{ $t('menu.archive') }}
|
||||
</DropdownItem>
|
||||
<Subscription
|
||||
class="has-no-shadow"
|
||||
:is-button="false"
|
||||
entity="project"
|
||||
:entity-id="project.id"
|
||||
:model-value="project.subscription"
|
||||
type="dropdown"
|
||||
@update:modelValue="setSubscriptionInStore"
|
||||
/>
|
||||
<DropdownItem
|
||||
:to="{ name: 'project.settings.webhooks', params: { projectId: project.id } }"
|
||||
icon="bolt"
|
||||
>
|
||||
{{ $t('project.webhooks.title') }}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
v-if="level < 2"
|
||||
:to="{ name: 'project.createFromParent', params: { parentProjectId: project.id } }"
|
||||
icon="layer-group"
|
||||
>
|
||||
{{ $t('menu.createProject') }}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
:to="{ name: 'project.settings.delete', params: { projectId: project.id } }"
|
||||
icon="trash-alt"
|
||||
class="has-text-danger"
|
||||
>
|
||||
{{ $t('menu.delete') }}
|
||||
</DropdownItem>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, watchEffect, type PropType} from 'vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import Dropdown from '@/components/misc/dropdown.vue'
|
||||
import DropdownItem from '@/components/misc/dropdown-item.vue'
|
||||
import Subscription from '@/components/misc/subscription.vue'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import type {ISubscription} from '@/modelTypes/ISubscription'
|
||||
|
||||
import {isSavedFilter} from '@/services/savedFilter'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object as PropType<IProject>,
|
||||
required: true,
|
||||
},
|
||||
level: {
|
||||
type: Number,
|
||||
},
|
||||
})
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
const subscription = ref<ISubscription | null>(null)
|
||||
watchEffect(() => {
|
||||
subscription.value = props.project.subscription ?? null
|
||||
})
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const backgroundsEnabled = computed(() => configStore.enabledBackgroundProviders?.length > 0)
|
||||
|
||||
function setSubscriptionInStore(sub: ISubscription) {
|
||||
subscription.value = sub
|
||||
const updatedProject = {
|
||||
...props.project,
|
||||
subscription: sub,
|
||||
}
|
||||
projectStore.setProject(updatedProject)
|
||||
}
|
||||
</script>
|