chore: move frontend files
This commit is contained in:
19
frontend/src/components/misc/ButtonLink.vue
Normal file
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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>
|
Reference in New Issue
Block a user