1
0

chore: move frontend files

This commit is contained in:
kolaente
2024-02-07 14:56:56 +01:00
parent 447641c222
commit fc4676315d
606 changed files with 0 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>

View File

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

View File

@ -0,0 +1,11 @@
<script setup lang="ts">
import datemathHelp from './datemathHelp.vue'
</script>
<template>
<Story>
<Variant title="Default">
<datemathHelp />
</Variant>
</Story>
</template>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@ -0,0 +1,5 @@
import {createAsyncComponent} from '@/helpers/createAsyncComponent'
const TipTap = createAsyncComponent(() => import('@/components/input/editor/TipTap.vue'))
export default TipTap

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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,
}),
]
},
})

View File

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

View File

@ -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()
},
}
},
}
}

View File

@ -0,0 +1,6 @@
export type UploadCallback = (files: File[] | FileList) => Promise<string[]>
export interface BottomAction {
title: string
action: () => void,
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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

View 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>

View File

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

View File

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

View File

@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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'],
},
],
},
]

View 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>

View 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>

View 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>

View 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>

View 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>

View File

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

View 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>

View 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"
>&hellip;</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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@ -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,
}
}

View 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>

View File

@ -0,0 +1,680 @@
<template>
<modal
:enabled="active"
:overflow="isNewTaskCommand"
@close="closeQuickActions"
>
<div class="card quick-actions">
<div
class="action-input"
:class="{'has-active-cmd': selectedCmd !== null}"
>
<div
v-if="selectedCmd !== null"
class="active-cmd tag"
>
{{ selectedCmd.title }}
</div>
<input
ref="searchInput"
v-model="query"
v-focus
class="input"
:class="{'is-loading': loading}"
:placeholder="placeholder"
@keyup="search"
@keydown.down.prevent="select(0, 0)"
@keyup.prevent.delete="unselectCmd"
@keyup.prevent.enter="doCmd"
@keyup.prevent.esc="closeQuickActions"
>
</div>
<div
v-if="hintText !== '' && !isNewTaskCommand"
class="help has-text-grey-light p-2"
>
{{ hintText }}
</div>
<QuickAddMagic v-if="isNewTaskCommand" />
<div
v-if="selectedCmd === null"
class="results"
>
<div
v-for="(r, k) in results"
:key="k"
class="result"
>
<span class="result-title">
{{ r.title }}
</span>
<div class="result-items">
<BaseButton
v-for="(i, key) in r.items"
:key="key"
:ref="(el: Element | ComponentPublicInstance | null) => setResultRefs(el, k, key)"
class="result-item-button"
:class="{'is-strikethrough': (i as DoAction<ITask>)?.done}"
@keydown.up.prevent="select(k, key - 1)"
@keydown.down.prevent="select(k, key + 1)"
@click.prevent.stop="doAction(r.type, i)"
@keyup.prevent.enter="doAction(r.type, i)"
@keyup.prevent.esc="searchInput?.focus()"
>
<template v-if="r.type === ACTION_TYPE.LABELS">
<XLabel :label="i" />
</template>
<template v-else-if="r.type === ACTION_TYPE.TASK">
<SingleTaskInlineReadonly
:task="i"
:show-project="true"
/>
</template>
<template v-else>
{{ i.title }}
</template>
</BaseButton>
</div>
</div>
</div>
</div>
</modal>
</template>
<script setup lang="ts">
import {ref, computed, watchEffect, shallowReactive, type ComponentPublicInstance} from 'vue'
import {useI18n} from 'vue-i18n'
import {useRouter} from 'vue-router'
import TaskService from '@/services/task'
import TeamService from '@/services/team'
import TeamModel from '@/models/team'
import ProjectModel from '@/models/project'
import BaseButton from '@/components/base/BaseButton.vue'
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue'
import XLabel from '@/components/tasks/partials/label.vue'
import SingleTaskInlineReadonly from '@/components/tasks/partials/singleTaskInlineReadonly.vue'
import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects'
import {useLabelStore} from '@/stores/labels'
import {useTaskStore} from '@/stores/tasks'
import {useAuthStore} from '@/stores/auth'
import {getHistory} from '@/modules/projectHistory'
import {parseTaskText, PrefixMode, PREFIXES} from '@/modules/parseTaskText'
import {success} from '@/message'
import type {ITeam} from '@/modelTypes/ITeam'
import type {ITask} from '@/modelTypes/ITask'
import type {IProject} from '@/modelTypes/IProject'
import type {IAbstract} from '@/modelTypes/IAbstract'
const {t} = useI18n({useScope: 'global'})
const router = useRouter()
const baseStore = useBaseStore()
const projectStore = useProjectStore()
const labelStore = useLabelStore()
const taskStore = useTaskStore()
const authStore = useAuthStore()
type DoAction<Type> = { type: ACTION_TYPE } & Type
enum ACTION_TYPE {
CMD = 'cmd',
TASK = 'task',
PROJECT = 'project',
TEAM = 'team',
LABELS = 'labels',
}
enum COMMAND_TYPE {
NEW_TASK = 'newTask',
NEW_PROJECT = 'newProject',
NEW_TEAM = 'newTeam',
}
enum SEARCH_MODE {
ALL = 'all',
TASKS = 'tasks',
PROJECTS = 'projects',
TEAMS = 'teams',
}
const query = ref('')
const selectedCmd = ref<Command | null>(null)
const foundTasks = ref<DoAction<ITask>[]>([])
const taskService = shallowReactive(new TaskService())
const foundTeams = ref<ITeam[]>([])
const teamService = shallowReactive(new TeamService())
const active = computed(() => baseStore.quickActionsActive)
watchEffect(() => {
if (!active.value) {
reset()
}
})
function closeQuickActions() {
baseStore.setQuickActionsActive(false)
}
const foundProjects = computed(() => {
const {project, text, labels, assignees} = parsedQuery.value
if (project !== null) {
return projectStore.searchProject(project ?? text)
.filter(p => Boolean(p))
}
if (labels.length > 0 || assignees.length > 0) {
return []
}
if (text === '') {
const history = getHistory()
return history.map((p) => projectStore.projects[p.id])
.filter(p => Boolean(p))
}
return projectStore.searchProject(project ?? text)
.filter(p => Boolean(p))
})
const foundLabels = computed(() => {
const {labels, text} = parsedQuery.value
if (text === '' && labels.length === 0) {
return []
}
if (labels.length > 0) {
return labelStore.filterLabelsByQuery([], labels[0])
}
return labelStore.filterLabelsByQuery([], text)
})
// FIXME: use fuzzysearch
const foundCommands = computed(() => availableCmds.value.filter((a) =>
a.title.toLowerCase().includes(query.value.toLowerCase()),
))
interface Result {
type: ACTION_TYPE
title: string
items: DoAction<IAbstract>
}
const results = computed<Result[]>(() => {
return [
{
type: ACTION_TYPE.CMD,
title: t('quickActions.commands'),
items: foundCommands.value,
},
{
type: ACTION_TYPE.PROJECT,
title: t('quickActions.projects'),
items: foundProjects.value,
},
{
type: ACTION_TYPE.TASK,
title: t('quickActions.tasks'),
items: foundTasks.value,
},
{
type: ACTION_TYPE.LABELS,
title: t('quickActions.labels'),
items: foundLabels.value,
},
{
type: ACTION_TYPE.TEAM,
title: t('quickActions.teams'),
items: foundTeams.value,
},
].filter((i) => i.items.length > 0)
})
const loading = computed(() =>
taskService.loading ||
projectStore.isLoading ||
teamService.loading,
)
interface Command {
type: COMMAND_TYPE
title: string
placeholder: string
action: () => Promise<void>
}
const commands = computed<{ [key in COMMAND_TYPE]: Command }>(() => ({
newTask: {
type: COMMAND_TYPE.NEW_TASK,
title: t('quickActions.cmds.newTask'),
placeholder: t('quickActions.newTask'),
action: newTask,
},
newProject: {
type: COMMAND_TYPE.NEW_PROJECT,
title: t('quickActions.cmds.newProject'),
placeholder: t('quickActions.newProject'),
action: newProject,
},
newTeam: {
type: COMMAND_TYPE.NEW_TEAM,
title: t('quickActions.cmds.newTeam'),
placeholder: t('quickActions.newTeam'),
action: newTeam,
},
}))
const placeholder = computed(() => selectedCmd.value?.placeholder || t('quickActions.placeholder'))
const currentProject = computed(() => Object.keys(baseStore.currentProject).length === 0
? null
: baseStore.currentProject,
)
const hintText = computed(() => {
if (selectedCmd.value !== null && currentProject.value !== null) {
switch (selectedCmd.value.type) {
case COMMAND_TYPE.NEW_TASK:
return t('quickActions.createTask', {
title: currentProject.value.title,
})
case COMMAND_TYPE.NEW_PROJECT:
return t('quickActions.createProject')
}
}
const prefixes =
PREFIXES[authStore.settings.frontendSettings.quickAddMagicMode] ?? PREFIXES[PrefixMode.Default]
return t('quickActions.hint', prefixes)
})
const availableCmds = computed(() => {
const cmds = []
if (currentProject.value !== null) {
cmds.push(commands.value.newTask, commands.value.newProject)
}
cmds.push(commands.value.newTeam)
return cmds
})
const parsedQuery = computed(() => parseTaskText(query.value, authStore.settings.frontendSettings.quickAddMagicMode))
const searchMode = computed(() => {
if (query.value === '') {
return SEARCH_MODE.ALL
}
const {text, project, labels, assignees} = parsedQuery.value
if (assignees.length === 0 && text !== '') {
return SEARCH_MODE.TASKS
}
if (
assignees.length === 0 &&
project !== null &&
text === '' &&
labels.length === 0
) {
return SEARCH_MODE.PROJECTS
}
if (
assignees.length > 0 &&
project === null &&
text === '' &&
labels.length === 0
) {
return SEARCH_MODE.TEAMS
}
return SEARCH_MODE.ALL
})
const isNewTaskCommand = computed(() => (
selectedCmd.value !== null &&
selectedCmd.value.type === COMMAND_TYPE.NEW_TASK
))
const taskSearchTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
type Filter = { by: string, value: string | number, comparator: string }
function filtersToParams(filters: Filter[]) {
const filter_by: Filter['by'][] = []
const filter_value: Filter['value'][] = []
const filter_comparator: Filter['comparator'][] = []
filters.forEach(({by, value, comparator}) => {
filter_by.push(by)
filter_value.push(value)
filter_comparator.push(comparator)
})
return {
filter_by,
filter_value,
filter_comparator,
}
}
function searchTasks() {
if (
searchMode.value !== SEARCH_MODE.ALL &&
searchMode.value !== SEARCH_MODE.TASKS &&
searchMode.value !== SEARCH_MODE.PROJECTS
) {
foundTasks.value = []
return
}
if (selectedCmd.value !== null) {
return
}
if (taskSearchTimeout.value !== null) {
clearTimeout(taskSearchTimeout.value)
taskSearchTimeout.value = null
}
const {text, project: projectName, labels} = parsedQuery.value
const filters: Filter[] = []
// FIXME: improve types
function addFilter(
by: Filter['by'],
value: Filter['value'],
comparator: Filter['comparator'],
) {
filters.push({
by,
value,
comparator,
})
}
if (projectName !== null) {
const project = projectStore.findProjectByExactname(projectName)
console.log({project})
if (project !== null) {
addFilter('project_id', project.id, 'equals')
}
}
if (labels.length > 0) {
const labelIds = labelStore.getLabelsByExactTitles(labels).map((l) => l.id)
if (labelIds.length > 0) {
addFilter('labels', labelIds.join(), 'in')
}
}
const params = {
s: text,
sort_by: 'done',
...filtersToParams(filters),
}
taskSearchTimeout.value = setTimeout(async () => {
const r = await taskService.getAll({}, params) as DoAction<ITask>[]
foundTasks.value = r.map((t) => {
t.type = ACTION_TYPE.TASK
return t
})
}, 150)
}
const teamSearchTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
function searchTeams() {
if (
searchMode.value !== SEARCH_MODE.ALL &&
searchMode.value !== SEARCH_MODE.TEAMS
) {
foundTeams.value = []
return
}
if (query.value === '' || selectedCmd.value !== null) {
return
}
if (teamSearchTimeout.value !== null) {
clearTimeout(teamSearchTimeout.value)
teamSearchTimeout.value = null
}
const {assignees} = parsedQuery.value
teamSearchTimeout.value = setTimeout(async () => {
const teamSearchPromises = assignees.map((t) =>
teamService.getAll({}, {s: t}),
)
const teamsResult = await Promise.all(teamSearchPromises)
foundTeams.value = teamsResult.flat().map((team) => {
team.title = team.name
return team
})
}, 150)
}
function search() {
searchTasks()
searchTeams()
}
const searchInput = ref<HTMLElement | null>(null)
async function doAction(type: ACTION_TYPE, item: DoAction) {
switch (type) {
case ACTION_TYPE.PROJECT:
closeQuickActions()
await router.push({
name: 'project.index',
params: {projectId: (item as DoAction<IProject>).id},
})
break
case ACTION_TYPE.TASK:
closeQuickActions()
await router.push({
name: 'task.detail',
params: {id: (item as DoAction<ITask>).id},
})
break
case ACTION_TYPE.TEAM:
closeQuickActions()
await router.push({
name: 'teams.edit',
params: {id: (item as DoAction<ITeam>).id},
})
break
case ACTION_TYPE.CMD:
query.value = ''
selectedCmd.value = item as DoAction<Command>
searchInput.value?.focus()
break
case ACTION_TYPE.LABELS:
query.value = '*' + item.title
searchInput.value?.focus()
searchTasks()
break
}
}
async function doCmd() {
if (results.value.length === 1 && results.value[0].items.length === 1) {
const result = results.value[0]
doAction(result.type, result.items[0])
return
}
if (selectedCmd.value === null || query.value === '') {
return
}
closeQuickActions()
await selectedCmd.value.action()
}
async function newTask() {
if (currentProject.value === null) {
return
}
const task = await taskStore.createNewTask({
title: query.value,
projectId: currentProject.value.id,
})
success({message: t('task.createSuccess')})
await router.push({name: 'task.detail', params: {id: task.id}})
}
async function newProject() {
if (currentProject.value === null) {
return
}
await projectStore.createProject(new ProjectModel({
title: query.value,
}))
success({message: t('project.create.createdSuccess')})
}
async function newTeam() {
const newTeam = new TeamModel({name: query.value})
const team = await teamService.create(newTeam)
await router.push({
name: 'teams.edit',
params: {id: team.id},
})
success({message: t('team.create.success')})
}
type BaseButtonInstance = InstanceType<typeof BaseButton>
const resultRefs = ref<(BaseButtonInstance | null)[][]>([])
function setResultRefs(el: Element | ComponentPublicInstance | null, index: number, key: number) {
if (resultRefs.value[index] === undefined) {
resultRefs.value[index] = []
}
resultRefs.value[index][key] = el as (BaseButtonInstance | null)
}
function select(parentIndex: number, index: number) {
if (index < 0 && parentIndex === 0) {
searchInput.value?.focus()
return
}
if (index < 0) {
parentIndex--
index = results.value[parentIndex].items.length - 1
}
let elems = resultRefs.value[parentIndex][index]
if (results.value[parentIndex].items.length === index) {
elems = resultRefs.value[parentIndex + 1] ? resultRefs.value[parentIndex + 1][0] : undefined
}
if (
typeof elems === 'undefined'
/* || elems.length === 0 */
) {
return
}
if (Array.isArray(elems)) {
elems[0].focus()
return
}
elems?.focus()
}
function unselectCmd() {
if (query.value !== '') {
return
}
selectedCmd.value = null
}
function reset() {
query.value = ''
selectedCmd.value = null
}
</script>
<style lang="scss" scoped>
.quick-actions {
overflow: hidden;
// FIXME: changed position should be an option of the modal
:deep(.modal-content) {
top: 3rem;
transform: translate(-50%, 0);
}
}
.action-input {
display: flex;
align-items: center;
.input {
border: 0;
font-size: 1.5rem;
}
&.has-active-cmd .input {
padding-left: .5rem;
}
}
.active-cmd {
font-size: 1.25rem;
margin-left: .5rem;
background-color: var(--grey-100);
color: var(--grey-800);
}
.results {
text-align: left;
width: 100%;
color: var(--grey-800);
}
.result-title {
background: var(--grey-100);
padding: .5rem;
display: block;
font-size: .75rem;
}
.result-item-button {
font-size: .9rem;
width: 100%;
background: transparent;
color: var(--grey-800);
text-align: left;
box-shadow: none;
border-radius: 0;
text-transform: none;
font-family: $family-sans-serif;
font-weight: normal;
padding: .5rem .75rem;
border: none;
cursor: pointer;
&:focus,
&:hover {
background: var(--grey-100);
box-shadow: none !important;
}
&:active {
background: var(--grey-100);
}
}
</style>

View File

@ -0,0 +1,328 @@
<template>
<div>
<p class="has-text-weight-bold">
{{ $t('project.share.links.title') }}
<span
v-tooltip="$t('project.share.links.explanation')"
class="is-size-7 has-text-grey is-italic ml-3"
>
{{ $t('project.share.links.what') }}
</span>
</p>
<div class="sharables-project">
<x-button
v-if="!(linkShares.length === 0 || showNewForm)"
icon="plus"
class="mb-4"
@click="showNewForm = true"
>
{{ $t('project.share.links.create') }}
</x-button>
<div
v-if="linkShares.length === 0 || showNewForm"
class="p-4"
>
<div class="field">
<label
class="label"
for="linkShareRight"
>
{{ $t('project.share.right.title') }}
</label>
<div class="control">
<div class="select">
<select
id="linkShareRight"
v-model="selectedRight"
>
<option :value="RIGHTS.READ">
{{ $t('project.share.right.read') }}
</option>
<option :value="RIGHTS.READ_WRITE">
{{ $t('project.share.right.readWrite') }}
</option>
<option :value="RIGHTS.ADMIN">
{{ $t('project.share.right.admin') }}
</option>
</select>
</div>
</div>
</div>
<div class="field">
<label
class="label"
for="linkShareName"
>
{{ $t('project.share.links.name') }}
</label>
<div class="control">
<input
id="linkShareName"
v-model="name"
v-tooltip="$t('project.share.links.nameExplanation')"
class="input"
:placeholder="$t('project.share.links.namePlaceholder')"
>
</div>
</div>
<div class="field">
<label
class="label"
for="linkSharePassword"
>
{{ $t('project.share.links.password') }}
</label>
<div class="control">
<input
id="linkSharePassword"
v-model="password"
v-tooltip="$t('project.share.links.passwordExplanation')"
type="password"
class="input"
:placeholder="$t('user.auth.passwordPlaceholder')"
>
</div>
</div>
<x-button
icon="plus"
@click="add(projectId)"
>
{{ $t('project.share.share') }}
</x-button>
</div>
<table
v-if="linkShares.length > 0"
class="table has-actions is-striped is-hoverable is-fullwidth"
>
<thead>
<tr>
<th />
<th>{{ $t('project.share.links.view') }}</th>
<th>{{ $t('project.share.attributes.delete') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="s in linkShares"
:key="s.id"
>
<td>
<p
v-if="s.name !== ''"
class="mb-2 is-italic"
>
{{ s.name }}
</p>
<p class="mb-2">
<i18n-t
keypath="project.share.links.sharedBy"
scope="global"
>
<strong>{{ getDisplayName(s.sharedBy) }}</strong>
</i18n-t>
</p>
<p class="mb-2">
<template v-if="s.right === RIGHTS.ADMIN">
<span class="icon is-small">
<icon icon="lock" />
</span>&nbsp;
{{ $t('project.share.right.admin') }}
</template>
<template v-else-if="s.right === RIGHTS.READ_WRITE">
<span class="icon is-small">
<icon icon="pen" />
</span>&nbsp;
{{ $t('project.share.right.readWrite') }}
</template>
<template v-else>
<span class="icon is-small">
<icon icon="users" />
</span>&nbsp;
{{ $t('project.share.right.read') }}
</template>
</p>
<div class="field has-addons no-input-mobile">
<div class="control">
<input
:value="getShareLink(s.hash, selectedView[s.id])"
class="input"
readonly
type="text"
>
</div>
<div class="control">
<x-button
v-tooltip="$t('misc.copy')"
:shadow="false"
@click="copy(getShareLink(s.hash, selectedView[s.id]))"
>
<span class="icon">
<icon icon="paste" />
</span>
</x-button>
</div>
</div>
</td>
<td>
<div class="select">
<select v-model="selectedView[s.id]">
<option
v-for="(title, key) in availableViews"
:key="key"
:value="key"
>
{{ title }}
</option>
</select>
</div>
</td>
<td class="actions">
<x-button
class="is-danger"
icon="trash-alt"
@click="
() => {
linkIdToDelete = s.id
showDeleteModal = true
}
"
/>
</td>
</tr>
</tbody>
</table>
</div>
<modal
:enabled="showDeleteModal"
@close="showDeleteModal = false"
@submit="remove(projectId)"
>
<template #header>
<span>{{ $t('project.share.links.remove') }}</span>
</template>
<template #text>
<p>{{ $t('project.share.links.removeText') }}</p>
</template>
</modal>
</div>
</template>
<script setup lang="ts">
import {ref, watch, computed, shallowReactive} from 'vue'
import {useI18n} from 'vue-i18n'
import {RIGHTS} from '@/constants/rights'
import LinkShareModel from '@/models/linkShare'
import type {ILinkShare} from '@/modelTypes/ILinkShare'
import type {IProject} from '@/modelTypes/IProject'
import LinkShareService from '@/services/linkShare'
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {success} from '@/message'
import {getDisplayName} from '@/models/user'
import type {ProjectView} from '@/types/ProjectView'
import {PROJECT_VIEWS} from '@/types/ProjectView'
import {useConfigStore} from '@/stores/config'
const props = defineProps({
projectId: {
default: 0,
required: false,
},
})
const {t} = useI18n({useScope: 'global'})
const linkShares = ref<ILinkShare[]>([])
const linkShareService = shallowReactive(new LinkShareService())
const selectedRight = ref(RIGHTS.READ)
const name = ref('')
const password = ref('')
const showDeleteModal = ref(false)
const linkIdToDelete = ref(0)
const showNewForm = ref(false)
type SelectedViewMapper = Record<IProject['id'], ProjectView>
const selectedView = ref<SelectedViewMapper>({})
const availableViews = computed<Record<ProjectView, string>>(() => ({
list: t('project.list.title'),
gantt: t('project.gantt.title'),
table: t('project.table.title'),
kanban: t('project.kanban.title'),
}))
const copy = useCopyToClipboard()
watch(
() => props.projectId,
load,
{immediate: true},
)
const configStore = useConfigStore()
const frontendUrl = computed(() => configStore.frontendUrl)
async function load(projectId: IProject['id']) {
// If projectId == 0 the project on the calling component wasn't already loaded, so we just bail out here
if (projectId === 0) {
return
}
const links = await linkShareService.getAll({projectId})
links.forEach((l: ILinkShare) => {
selectedView.value[l.id] = 'list'
})
linkShares.value = links
}
async function add(projectId: IProject['id']) {
const newLinkShare = new LinkShareModel({
right: selectedRight.value,
projectId,
name: name.value,
password: password.value,
})
await linkShareService.create(newLinkShare)
selectedRight.value = RIGHTS.READ
name.value = ''
password.value = ''
showNewForm.value = false
success({message: t('project.share.links.createSuccess')})
await load(projectId)
}
async function remove(projectId: IProject['id']) {
try {
await linkShareService.delete(new LinkShareModel({
id: linkIdToDelete.value,
projectId,
}))
success({message: t('project.share.links.deleteSuccess')})
await load(projectId)
} finally {
showDeleteModal.value = false
}
}
function getShareLink(hash: string, view: ProjectView = PROJECT_VIEWS.LIST) {
return frontendUrl.value + 'share/' + hash + '/auth?view=' + view
}
</script>
<style lang="scss" scoped>
// FIXME: I think this is not needed
.sharables-project:not(.card-content) {
overflow-y: auto
}
</style>

View File

@ -0,0 +1,373 @@
<template>
<div>
<p class="has-text-weight-bold">
{{ $t('project.share.userTeam.shared', {type: shareTypeNames}) }}
</p>
<div v-if="userIsAdmin">
<div class="field has-addons">
<p
class="control is-expanded"
:class="{ 'is-loading': searchService.loading }"
>
<Multiselect
v-model="sharable"
:loading="searchService.loading"
:placeholder="$t('misc.searchPlaceholder')"
:search-results="found"
:label="searchLabel"
@search="find"
/>
</p>
<p class="control">
<x-button @click="add()">
{{ $t('project.share.share') }}
</x-button>
</p>
</div>
</div>
<table
v-if="sharables.length > 0"
class="table has-actions is-striped is-hoverable is-fullwidth mb-4"
>
<tbody>
<tr
v-for="s in sharables"
:key="s.id"
>
<template v-if="shareType === 'user'">
<td>{{ getDisplayName(s) }}</td>
<td>
<template v-if="s.id === userInfo.id">
<b class="is-success">{{ $t('project.share.userTeam.you') }}</b>
</template>
</td>
</template>
<template v-if="shareType === 'team'">
<td>
<router-link
:to="{
name: 'teams.edit',
params: { id: s.id },
}"
>
{{ s.name }}
</router-link>
</td>
</template>
<td class="type">
<template v-if="s.right === RIGHTS.ADMIN">
<span class="icon is-small">
<icon icon="lock" />
</span>
{{ $t('project.share.right.admin') }}
</template>
<template v-else-if="s.right === RIGHTS.READ_WRITE">
<span class="icon is-small">
<icon icon="pen" />
</span>
{{ $t('project.share.right.readWrite') }}
</template>
<template v-else>
<span class="icon is-small">
<icon icon="users" />
</span>
{{ $t('project.share.right.read') }}
</template>
</td>
<td
v-if="userIsAdmin"
class="actions"
>
<div class="select">
<select
v-model="selectedRight[s.id]"
class="mr-2"
@change="toggleType(s)"
>
<option
:selected="s.right === RIGHTS.READ"
:value="RIGHTS.READ"
>
{{ $t('project.share.right.read') }}
</option>
<option
:selected="s.right === RIGHTS.READ_WRITE"
:value="RIGHTS.READ_WRITE"
>
{{ $t('project.share.right.readWrite') }}
</option>
<option
:selected="s.right === RIGHTS.ADMIN"
:value="RIGHTS.ADMIN"
>
{{ $t('project.share.right.admin') }}
</option>
</select>
</div>
<x-button
class="is-danger"
icon="trash-alt"
@click="
() => {
sharable = s
showDeleteModal = true
}
"
/>
</td>
</tr>
</tbody>
</table>
<Nothing v-else>
{{ $t('project.share.userTeam.notShared', {type: shareTypeNames}) }}
</Nothing>
<modal
:enabled="showDeleteModal"
@close="showDeleteModal = false"
@submit="deleteSharable()"
>
<template #header>
<span>{{
$t('project.share.userTeam.removeHeader', {type: shareTypeName, sharable: sharableName})
}}</span>
</template>
<template #text>
<p>{{ $t('project.share.userTeam.removeText', {type: shareTypeName, sharable: sharableName}) }}</p>
</template>
</modal>
</div>
</template>
<script lang="ts">
export default {name: 'UserTeamShare'}
</script>
<script setup lang="ts">
import {ref, reactive, computed, shallowReactive, type Ref} from 'vue'
import type {PropType} from 'vue'
import {useI18n} from 'vue-i18n'
import UserProjectService from '@/services/userProject'
import UserProjectModel from '@/models/userProject'
import type {IUserProject} from '@/modelTypes/IUserProject'
import UserService from '@/services/user'
import UserModel, { getDisplayName } from '@/models/user'
import type {IUser} from '@/modelTypes/IUser'
import TeamProjectService from '@/services/teamProject'
import TeamProjectModel from '@/models/teamProject'
import type { ITeamProject } from '@/modelTypes/ITeamProject'
import TeamService from '@/services/team'
import TeamModel from '@/models/team'
import type {ITeam} from '@/modelTypes/ITeam'
import {RIGHTS} from '@/constants/rights'
import Multiselect from '@/components/input/multiselect.vue'
import Nothing from '@/components/misc/nothing.vue'
import {success} from '@/message'
import {useAuthStore} from '@/stores/auth'
// FIXME: I think this whole thing can now only manage user/team sharing for projects? Maybe remove a little generalization?
const props = defineProps({
type: {
type: String as PropType<'project'>,
default: '',
},
shareType: {
type: String as PropType<'user' | 'team'>,
default: '',
},
id: {
type: Number,
default: 0,
},
userIsAdmin: {
type: Boolean,
default: false,
},
})
const {t} = useI18n({useScope: 'global'})
// This user service is a userProjectService, depending on the type we are using
let stuffService: UserProjectService | TeamProjectService
let stuffModel: IUserProject | ITeamProject
let searchService: UserService | TeamService
let sharable: Ref<IUser | ITeam>
const searchLabel = ref('')
const selectedRight = ref({})
// This holds either teams or users who this namepace or project is shared with
const sharables = ref([])
const showDeleteModal = ref(false)
const authStore = useAuthStore()
const userInfo = computed(() => authStore.info)
function createShareTypeNameComputed(count: number) {
return computed(() => {
if (props.shareType === 'user') {
return t('project.share.userTeam.typeUser', count)
}
if (props.shareType === 'team') {
return t('project.share.userTeam.typeTeam', count)
}
return ''
})
}
const shareTypeNames = createShareTypeNameComputed(2)
const shareTypeName = createShareTypeNameComputed(1)
const sharableName = computed(() => {
if (props.type === 'project') {
return t('project.list.title')
}
return ''
})
if (props.shareType === 'user') {
searchService = shallowReactive(new UserService())
// eslint-disable-next-line vue/no-ref-as-operand
sharable = ref(new UserModel())
searchLabel.value = 'username'
if (props.type === 'project') {
stuffService = shallowReactive(new UserProjectService())
stuffModel = reactive(new UserProjectModel({projectId: props.id}))
} else {
throw new Error('Unknown type: ' + props.type)
}
} else if (props.shareType === 'team') {
searchService = new TeamService()
// eslint-disable-next-line vue/no-ref-as-operand
sharable = ref(new TeamModel())
searchLabel.value = 'name'
if (props.type === 'project') {
stuffService = shallowReactive(new TeamProjectService())
stuffModel = reactive(new TeamProjectModel({projectId: props.id}))
} else {
throw new Error('Unknown type: ' + props.type)
}
} else {
throw new Error('Unkown share type')
}
load()
async function load() {
sharables.value = await stuffService.getAll(stuffModel)
sharables.value.forEach(({id, right}) =>
selectedRight.value[id] = right,
)
}
async function deleteSharable() {
if (props.shareType === 'user') {
stuffModel.userId = sharable.value.username
} else if (props.shareType === 'team') {
stuffModel.teamId = sharable.value.id
}
await stuffService.delete(stuffModel)
showDeleteModal.value = false
for (const i in sharables.value) {
if (
(sharables.value[i].username === stuffModel.userId && props.shareType === 'user') ||
(sharables.value[i].id === stuffModel.teamId && props.shareType === 'team')
) {
sharables.value.splice(i, 1)
}
}
success({
message: t('project.share.userTeam.removeSuccess', {
type: shareTypeName.value,
sharable: sharableName.value,
}),
})
}
async function add(admin) {
if (admin === null) {
admin = false
}
stuffModel.right = RIGHTS.READ
if (admin) {
stuffModel.right = RIGHTS.ADMIN
}
if (props.shareType === 'user') {
stuffModel.userId = sharable.value.username
} else if (props.shareType === 'team') {
stuffModel.teamId = sharable.value.id
}
await stuffService.create(stuffModel)
success({message: t('project.share.userTeam.addedSuccess', {type: shareTypeName.value})})
await load()
}
async function toggleType(sharable) {
if (
selectedRight.value[sharable.id] !== RIGHTS.ADMIN &&
selectedRight.value[sharable.id] !== RIGHTS.READ &&
selectedRight.value[sharable.id] !== RIGHTS.READ_WRITE
) {
selectedRight.value[sharable.id] = RIGHTS.READ
}
stuffModel.right = selectedRight.value[sharable.id]
if (props.shareType === 'user') {
stuffModel.userId = sharable.username
} else if (props.shareType === 'team') {
stuffModel.teamId = sharable.id
}
const r = await stuffService.update(stuffModel)
for (const i in sharables.value) {
if (
(sharables.value[i].username ===
stuffModel.userId &&
props.shareType === 'user') ||
(sharables.value[i].id === stuffModel.teamId &&
props.shareType === 'team')
) {
sharables.value[i].right = r.right
}
}
success({message: t('project.share.userTeam.updatedSuccess', {type: shareTypeName.value})})
}
const found = ref([])
const currentUserId = computed(() => authStore.info.id)
async function find(query: string) {
if (query === '') {
found.value = []
return
}
const results = await searchService.getAll({}, {s: query})
found.value = results
.filter(m => {
if(props.shareType === 'user' && m.id === currentUserId.value) {
return false
}
return typeof sharables.value.find(s => s.id === m.id) === 'undefined'
})
}
</script>

View File

@ -0,0 +1,277 @@
<template>
<Loading
v-if="props.isLoading && !ganttBars.length || dayjsLanguageLoading"
class="gantt-container"
/>
<div
v-else
ref="ganttContainer"
class="gantt-container"
>
<GGanttChart
:date-format="DAYJS_ISO_DATE_FORMAT"
:chart-start="isoToKebabDate(filters.dateFrom)"
:chart-end="isoToKebabDate(filters.dateTo)"
precision="day"
bar-start="startDate"
bar-end="endDate"
:grid="true"
:width="ganttChartWidth"
@dragendBar="updateGanttTask"
@dblclickBar="openTask"
>
<template #timeunit="{value, date}">
<div
class="timeunit-wrapper"
:class="{'today': dateIsToday(date)}"
>
<span>{{ value }}</span>
<span class="weekday">
{{ weekDayFromDate(date) }}
</span>
</div>
</template>
<GGanttRow
v-for="(bar, k) in ganttBars"
:key="k"
label=""
:bars="bar"
/>
</GGanttChart>
</div>
</template>
<script setup lang="ts">
import {computed, ref, watch, toRefs, onActivated} from 'vue'
import {useRouter} from 'vue-router'
import {getHexColor} from '@/models/task'
import {colorIsDark} from '@/helpers/color/colorIsDark'
import {isoToKebabDate} from '@/helpers/time/isoToKebabDate'
import {parseKebabDate} from '@/helpers/time/parseKebabDate'
import type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask'
import type {DateISO} from '@/types/DateISO'
import type {GanttFilters} from '@/views/project/helpers/useGanttFilters'
import {
extendDayjs,
GGanttChart,
GGanttRow,
type GanttBarObject,
} from '@infectoone/vue-ganttastic'
import Loading from '@/components/misc/loading.vue'
import {MILLISECONDS_A_DAY} from '@/constants/date'
import {useWeekDayFromDate} from '@/helpers/time/formatDate'
import dayjs from 'dayjs'
import {useDayjsLanguageSync} from '@/i18n/useDayjsLanguageSync'
export interface GanttChartProps {
isLoading: boolean,
filters: GanttFilters,
tasks: Map<ITask['id'], ITask>,
defaultTaskStartDate: DateISO
defaultTaskEndDate: DateISO
}
const props = defineProps<GanttChartProps>()
const emit = defineEmits<{
(e: 'update:task', task: ITaskPartialWithId): void
}>()
const DAYJS_ISO_DATE_FORMAT = 'YYYY-MM-DD'
const {tasks, filters} = toRefs(props)
// setup dayjs for vue-ganttastic
// const dayjsLanguageLoading = ref(false)
const dayjsLanguageLoading = useDayjsLanguageSync(dayjs)
extendDayjs()
const ganttContainer = ref(null)
const router = useRouter()
const dateFromDate = computed(() => new Date(new Date(filters.value.dateFrom).setHours(0,0,0,0)))
const dateToDate = computed(() => new Date(new Date(filters.value.dateTo).setHours(23,59,0,0)))
const DAY_WIDTH_PIXELS = 30
const ganttChartWidth = computed(() => {
const ganttContainerReference = ganttContainer?.value
const ganttContainerWidth = ganttContainerReference ? (ganttContainerReference['clientWidth'] ?? 0) : 0
const dateDiff = Math.floor((dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY)
const calculatedWidth = dateDiff * DAY_WIDTH_PIXELS
return (calculatedWidth > ganttContainerWidth) ? calculatedWidth + 'px' : '100%'
})
const ganttBars = ref<GanttBarObject[][]>([])
/**
* Update ganttBars when tasks change
*/
watch(
tasks,
() => {
ganttBars.value = []
tasks.value.forEach(t => ganttBars.value.push(transformTaskToGanttBar(t)))
},
{deep: true, immediate: true},
)
function transformTaskToGanttBar(t: ITask) {
const black = 'var(--grey-800)'
const taskColor = getHexColor(t.hexColor)
let textColor = black
let backgroundColor = 'var(--grey-100)'
if(t.startDate) {
backgroundColor = taskColor ?? ''
if(typeof taskColor === 'undefined') {
textColor = 'white'
backgroundColor = 'var(--primary)'
} else if(colorIsDark(taskColor)) {
textColor = black
} else {
textColor = 'white'
}
}
return [{
startDate: isoToKebabDate(t.startDate ? t.startDate.toISOString() : props.defaultTaskStartDate),
endDate: isoToKebabDate(t.endDate ? t.endDate.toISOString() : props.defaultTaskEndDate),
ganttBarConfig: {
id: String(t.id),
label: t.title,
hasHandles: true,
style: {
color: textColor,
backgroundColor,
border: t.startDate ? '' : '2px dashed var(--grey-300)',
'text-decoration': t.done ? 'line-through' : null,
},
},
} as GanttBarObject]
}
async function updateGanttTask(e: {
bar: GanttBarObject;
e: MouseEvent;
datetime?: string | undefined;
}) {
emit('update:task', {
id: Number(e.bar.ganttBarConfig.id),
startDate: new Date(parseKebabDate(e.bar.startDate).setHours(0,0,0,0)),
endDate: new Date(parseKebabDate(e.bar.endDate).setHours(23,59,0,0)),
})
}
function openTask(e: {
bar: GanttBarObject;
e: MouseEvent;
datetime?: string | undefined;
}) {
router.push({
name: 'task.detail',
params: {id: e.bar.ganttBarConfig.id},
state: {backdropView: router.currentRoute.value.fullPath},
})
}
const weekDayFromDate = useWeekDayFromDate()
const today = ref(new Date())
onActivated(() => today.value = new Date())
const dateIsToday = computed(() => (date: Date) => {
return (
date.getDate() === today.value.getDate() &&
date.getMonth() === today.value.getMonth() &&
date.getFullYear() === today.value.getFullYear()
)
})
</script>
<style scoped lang="scss">
.gantt-container {
overflow-x: auto;
}
</style>
<style lang="scss">
// Not scoped because we need to style the elements inside the gantt chart component
.g-gantt-chart {
width: max-content;
}
.g-gantt-row-label {
display: none !important;
}
.g-upper-timeunit, .g-timeunit {
background: var(--white) !important;
font-family: $vikunja-font;
}
.g-upper-timeunit {
font-weight: bold;
border-right: 1px solid var(--grey-200);
padding: .5rem 0;
}
.g-timeunit .timeunit-wrapper {
padding: 0.5rem 0;
font-size: 1rem !important;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
&.today {
background: var(--primary);
color: var(--white);
border-radius: 5px 5px 0 0;
font-weight: bold;
}
.weekday {
font-size: 0.8rem;
}
}
.g-timeaxis {
height: auto !important;
box-shadow: none !important;
}
.g-gantt-row > .g-gantt-row-bars-container {
border-bottom: none !important;
border-top: none !important;
}
.g-gantt-row:nth-child(odd) {
background: hsla(var(--grey-100-hsl), .5);
}
.g-gantt-bar {
border-radius: $radius * 1.5;
overflow: visible;
font-size: .85rem;
&-handle-left,
&-handle-right {
width: 6px !important;
height: 75% !important;
opacity: .75 !important;
border-radius: $radius !important;
margin-top: 4px;
}
}
</style>

View File

@ -0,0 +1,84 @@
<template>
<form
class="add-new-task"
@submit.prevent="createTask"
>
<CustomTransition name="width">
<input
v-if="newTaskFieldActive"
ref="newTaskTitleField"
v-model="newTaskTitle"
class="input"
type="text"
@blur="hideCreateNewTask"
@keyup.esc="newTaskFieldActive = false"
>
</CustomTransition>
<x-button
:shadow="false"
icon="plus"
@click="showCreateTaskOrCreate"
>
{{ $t('task.new') }}
</x-button>
</form>
</template>
<script setup lang="ts">
import {nextTick, ref} from 'vue'
import type {ITask} from '@/modelTypes/ITask'
import CustomTransition from '@/components/misc/CustomTransition.vue'
const emit = defineEmits<{
(e: 'createTask', title: string): Promise<ITask>
}>()
const newTaskFieldActive = ref(false)
const newTaskTitleField = ref()
const newTaskTitle = ref('')
function showCreateTaskOrCreate() {
if (!newTaskFieldActive.value) {
// Timeout to not send the form if the field isn't even shown
setTimeout(() => {
newTaskFieldActive.value = true
nextTick(() => newTaskTitleField.value.focus())
}, 100)
} else {
createTask()
}
}
function hideCreateNewTask() {
if (newTaskTitle.value === '') {
nextTick(() => (newTaskFieldActive.value = false))
}
}
async function createTask() {
if (!newTaskFieldActive.value) {
return
}
await emit('createTask', newTaskTitle.value)
newTaskTitle.value = ''
hideCreateNewTask()
}
</script>
<style scoped lang="scss">
.add-new-task {
padding: 1rem .7rem .4rem .7rem;
display: flex;
max-width: 450px;
.input {
margin-right: .7rem;
font-size: .8rem;
}
.button {
font-size: .68rem;
}
}
</style>

View File

@ -0,0 +1,270 @@
<template>
<div
ref="taskAdd"
class="task-add"
>
<div class="add-task__field field is-grouped">
<p class="control has-icons-left has-icons-right is-expanded">
<textarea
ref="newTaskInput"
v-model="newTaskTitle"
v-focus
class="add-task-textarea input"
:class="{'textarea-empty': newTaskTitle === ''}"
:placeholder="$t('project.list.addPlaceholder')"
rows="1"
@keyup="resetEmptyTitleError"
@keydown.enter="handleEnter"
/>
<span class="icon is-small is-left">
<icon icon="tasks" />
</span>
<QuickAddMagic :highlight-hint-icon="taskAddHovered" />
</p>
<p class="control">
<x-button
class="add-task-button"
:disabled="newTaskTitle === '' || loading || undefined"
icon="plus"
:loading="loading"
:aria-label="$t('project.list.add')"
@click="addTask()"
>
<span class="button-text">
{{ $t('project.list.add') }}
</span>
</x-button>
</p>
</div>
<Expandable :open="errorMessage !== ''">
<p
v-if="errorMessage !== ''"
class="pt-3 mt-0 help is-danger"
>
{{ errorMessage }}
</p>
</Expandable>
</div>
</template>
<script setup lang="ts">
import {computed, ref} from 'vue'
import {useI18n} from 'vue-i18n'
import {useElementHover} from '@vueuse/core'
import {RELATION_KIND} from '@/types/IRelationKind'
import type {ITask} from '@/modelTypes/ITask'
import Expandable from '@/components/base/Expandable.vue'
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue'
import {parseSubtasksViaIndention} from '@/helpers/parseSubtasksViaIndention'
import TaskRelationService from '@/services/taskRelation'
import TaskRelationModel from '@/models/taskRelation'
import {getLabelsFromPrefix} from '@/modules/parseTaskText'
import {useAuthStore} from '@/stores/auth'
import {useTaskStore} from '@/stores/tasks'
import {useAutoHeightTextarea} from '@/composables/useAutoHeightTextarea'
const props = defineProps({
defaultPosition: {
type: Number,
required: false,
},
})
const emit = defineEmits(['taskAdded'])
const newTaskTitle = ref('')
const newTaskInput = useAutoHeightTextarea(newTaskTitle)
const {t} = useI18n({useScope: 'global'})
const authStore = useAuthStore()
const taskStore = useTaskStore()
// enable only if we don't have a modal
// onStartTyping(() => {
// if (newTaskInput.value === null || document.activeElement === newTaskInput.value) {
// return
// }
// newTaskInput.value.focus()
// })
const taskAdd = ref<HTMLTextAreaElement | null>(null)
const taskAddHovered = useElementHover(taskAdd)
const errorMessage = ref('')
function resetEmptyTitleError(e: KeyboardEvent) {
if (
(e.which <= 90 && e.which >= 48 || e.which >= 96 && e.which <= 105)
&& newTaskTitle.value !== ''
) {
errorMessage.value = ''
}
}
const loading = computed(() => taskStore.isLoading)
async function addTask() {
if (newTaskTitle.value === '') {
errorMessage.value = t('project.create.addTitleRequired')
return
}
errorMessage.value = ''
if (loading.value) {
return
}
const taskTitleBackup = newTaskTitle.value
// This allows us to find the tasks with the title they had before being parsed
// by quick add magic.
const createdTasks: { [key: ITask['title']]: ITask } = {}
const tasksToCreate = parseSubtasksViaIndention(newTaskTitle.value, authStore.settings.frontendSettings.quickAddMagicMode)
// We ensure all labels exist prior to passing them down to the create task method
// In the store it will only ever see one task at a time so there's no way to reliably
// check if a new label was created before (because everything happens async).
const allLabels = tasksToCreate.map(({title}) => getLabelsFromPrefix(title, authStore.settings.frontendSettings.quickAddMagicMode) ?? [])
await taskStore.ensureLabelsExist(allLabels.flat())
const newTasks = tasksToCreate.map(async ({title, project}) => {
if (title === '') {
return
}
// If the task has a project specified, make sure to use it
let projectId = null
if (project !== null) {
projectId = await taskStore.findProjectId({project, projectId: 0})
}
const task = await taskStore.createNewTask({
title,
projectId: projectId || authStore.settings.defaultProjectId,
position: props.defaultPosition,
})
createdTasks[title] = task
return task
})
try {
newTaskTitle.value = ''
await Promise.all(newTasks)
const taskRelationService = new TaskRelationService()
const allParentTasks = tasksToCreate.filter(t => t.parent !== null).map(t => t.parent)
const relations = tasksToCreate.map(async t => {
const createdTask = createdTasks[t.title]
if (typeof createdTask === 'undefined') {
return
}
const isParent = allParentTasks.includes(t.title)
if (t.parent === null && !isParent) {
emit('taskAdded', createdTask)
return
}
const createdParentTask = createdTasks[t.parent]
if (typeof createdTask === 'undefined' || typeof createdParentTask === 'undefined') {
return
}
const rel = await taskRelationService.create(new TaskRelationModel({
taskId: createdTask.id,
otherTaskId: createdParentTask.id,
relationKind: RELATION_KIND.PARENTTASK,
}))
createdTask.relatedTasks[RELATION_KIND.PARENTTASK] = [{
...createdParentTask,
relatedTasks: {}, // To avoid endless references
}]
// we're only emitting here so that the relation shows up in the project
emit('taskAdded', createdTask)
createdParentTask.relatedTasks[RELATION_KIND.SUBTASK] = [{
...createdTask,
relatedTasks: {}, // To avoid endless references
}]
emit('taskAdded', createdParentTask)
return rel
})
await Promise.all(relations)
} catch (e) {
newTaskTitle.value = taskTitleBackup
if (e?.message === 'NO_PROJECT') {
errorMessage.value = t('project.create.addProjectRequired')
return
}
throw e
}
}
function handleEnter(e: KeyboardEvent) {
// when pressing shift + enter we want to continue as we normally would. Otherwise, we want to create
// the new task(s). The vue event modifier don't allow this, hence this method.
if (e.shiftKey) {
return
}
e.preventDefault()
addTask()
}
function focusTaskInput() {
newTaskInput.value?.focus()
}
defineExpose({
focusTaskInput,
})
</script>
<style lang="scss" scoped>
.task-add,
// overwrite bulma styles
.task-add .add-task__field {
margin-bottom: 0;
}
.add-task-button {
height: 100% !important;
@media screen and (max-width: $mobile) {
.button-text {
display: none;
}
:deep(.icon) {
margin: 0 !important;
}
}
}
.add-task-textarea {
transition: border-color $transition;
resize: none;
}
// Adding this class when the textarea has no text prevents the textarea from wrapping the placeholder.
.textarea-empty {
white-space: nowrap;
text-overflow: ellipsis;
}
.control.has-icons-left .icon,
.control.has-icons-right .icon {
transition: all $transition;
}
</style>
<style>
button.show-helper-text {
right: 0;
}
</style>

View File

@ -0,0 +1,94 @@
<script setup lang="ts">
import type {IUser} from '@/modelTypes/IUser'
import BaseButton from '@/components/base/BaseButton.vue'
import User from '@/components/misc/user.vue'
import {computed} from 'vue'
const {
assignees,
remove,
disabled,
avatarSize = 30,
inline = false,
} = defineProps<{
assignees: IUser[],
remove?: (user: IUser) => void,
disabled?: boolean,
avatarSize?: number,
inline?: boolean,
}>()
const hasDelete = computed(() => typeof remove !== 'undefined' && !disabled)
</script>
<template>
<div
class="assignees-list"
:class="{'is-inline': inline}"
>
<span
v-for="user in assignees"
:key="user.id"
class="assignee"
>
<User
:key="'user'+user.id"
:avatar-size="avatarSize"
:show-username="false"
:user="user"
:class="{'m-2': hasDelete}"
/>
<BaseButton
v-if="hasDelete"
:key="'delete'+user.id"
class="remove-assignee"
@click="remove(user)"
>
<icon icon="times" />
</BaseButton>
</span>
</div>
</template>
<style scoped lang="scss">
.assignees-list {
display: flex;
&.is-inline :deep(.user) {
display: inline;
}
&:hover .assignee:not(:first-child) {
margin-left: -1rem;
}
}
.assignee {
position: relative;
transition: all $transition;
&:not(:first-child) {
margin-left: -1.5rem;
}
:deep(.user img) {
border: 2px solid var(--white);
margin-right: 0;
}
}
.remove-assignee {
position: absolute;
top: 4px;
left: 2px;
color: var(--danger);
background: var(--white);
padding: 0 4px;
display: block;
border-radius: 100%;
font-size: .75rem;
width: 18px;
height: 18px;
z-index: 100;
}
</style>

View File

@ -0,0 +1,445 @@
<template>
<div class="attachments">
<h3>
<span class="icon is-grey">
<icon icon="paperclip" />
</span>
{{ $t('task.attachment.title') }}
</h3>
<input
v-if="editEnabled"
id="files"
ref="filesRef"
:disabled="loading || undefined"
multiple
type="file"
@change="uploadNewAttachment()"
>
<ProgressBar
v-if="attachmentService.uploadProgress > 0"
:value="attachmentService.uploadProgress * 100"
is-primary
/>
<div
v-if="attachments.length > 0"
class="files"
>
<!-- FIXME: don't use a for element that wraps other links / buttons
Instead: overlay element with button that is inside.
-->
<a
v-for="a in attachments"
:key="a.id"
class="attachment"
@click="viewOrDownload(a)"
>
<div class="filename">
{{ a.file.name }}
<span
v-if="task.coverImageAttachmentId === a.id"
class="is-task-cover"
>
{{ $t('task.attachment.usedAsCover') }}
</span>
</div>
<div class="info">
<p class="attachment-info-meta">
<i18n-t
keypath="task.attachment.createdBy"
scope="global"
>
<span v-tooltip="formatDateLong(a.created)">
{{ formatDateSince(a.created) }}
</span>
<User
:avatar-size="24"
:user="a.createdBy"
:is-inline="true"
/>
</i18n-t>
<span>
{{ getHumanSize(a.file.size) }}
</span>
<span v-if="a.file.mime">
{{ a.file.mime }}
</span>
</p>
<p>
<BaseButton
v-tooltip="$t('task.attachment.downloadTooltip')"
class="attachment-info-meta-button"
@click.prevent.stop="downloadAttachment(a)"
>
{{ $t('misc.download') }}
</BaseButton>
<BaseButton
v-tooltip="$t('task.attachment.copyUrlTooltip')"
class="attachment-info-meta-button"
@click.stop="copyUrl(a)"
>
{{ $t('task.attachment.copyUrl') }}
</BaseButton>
<BaseButton
v-if="editEnabled"
v-tooltip="$t('task.attachment.deleteTooltip')"
class="attachment-info-meta-button"
@click.prevent.stop="setAttachmentToDelete(a)"
>
{{ $t('misc.delete') }}
</BaseButton>
<BaseButton
v-if="editEnabled"
class="attachment-info-meta-button"
@click.prevent.stop="setCoverImage(task.coverImageAttachmentId === a.id ? null : a)"
>
{{
task.coverImageAttachmentId === a.id
? $t('task.attachment.unsetAsCover')
: $t('task.attachment.setAsCover')
}}
</BaseButton>
</p>
</div>
</a>
</div>
<x-button
v-if="editEnabled"
:disabled="loading"
class="mb-4"
icon="cloud-upload-alt"
variant="secondary"
:shadow="false"
@click="filesRef?.click()"
>
{{ $t('task.attachment.upload') }}
</x-button>
<!-- Dropzone -->
<Teleport to="body">
<div
v-if="editEnabled"
:class="{ hidden: !isOverDropZone }"
class="dropzone"
>
<div class="drop-hint">
<div class="icon">
<icon icon="cloud-upload-alt" />
</div>
<div class="hint">
{{ $t('task.attachment.drop') }}
</div>
</div>
</div>
</Teleport>
<!-- Delete modal -->
<modal
:enabled="attachmentToDelete !== null"
@close="setAttachmentToDelete(null)"
@submit="deleteAttachment()"
>
<template #header>
<span>{{ $t('task.attachment.delete') }}</span>
</template>
<template #text>
<p>
{{ $t('task.attachment.deleteText1', {filename: attachmentToDelete.file.name}) }}<br>
<strong class="has-text-white">{{ $t('misc.cannotBeUndone') }}</strong>
</p>
</template>
</modal>
<!-- Attachment image modal -->
<modal
:enabled="attachmentImageBlobUrl !== null"
@close="attachmentImageBlobUrl = null"
>
<img
:src="attachmentImageBlobUrl"
alt=""
>
</modal>
</div>
</template>
<script setup lang="ts">
import {ref, shallowReactive, computed} from 'vue'
import {useDropZone} from '@vueuse/core'
import User from '@/components/misc/user.vue'
import ProgressBar from '@/components/misc/ProgressBar.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import AttachmentService from '@/services/attachment'
import {SUPPORTED_IMAGE_SUFFIX} from '@/models/attachment'
import type {IAttachment} from '@/modelTypes/IAttachment'
import type {ITask} from '@/modelTypes/ITask'
import {useAttachmentStore} from '@/stores/attachments'
import {formatDateSince, formatDateLong} from '@/helpers/time/formatDate'
import {uploadFiles, generateAttachmentUrl} from '@/helpers/attachments'
import {getHumanSize} from '@/helpers/getHumanSize'
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {error, success} from '@/message'
import {useTaskStore} from '@/stores/tasks'
import {useI18n} from 'vue-i18n'
const {
task,
editEnabled = true,
} = defineProps<{
task: ITask,
editEnabled: boolean,
}>()
// FIXME: this should go through the store
const emit = defineEmits(['taskChanged'])
const taskStore = useTaskStore()
const {t} = useI18n({useScope: 'global'})
const attachmentService = shallowReactive(new AttachmentService())
const attachmentStore = useAttachmentStore()
const attachments = computed(() => attachmentStore.attachments)
const loading = computed(() => attachmentService.loading || taskStore.isLoading)
function onDrop(files: File[] | null) {
if (files && files.length !== 0) {
uploadFilesToTask(files)
}
}
const {isOverDropZone} = useDropZone(document, onDrop)
function downloadAttachment(attachment: IAttachment) {
attachmentService.download(attachment)
}
const filesRef = ref<HTMLInputElement | null>(null)
function uploadNewAttachment() {
const files = filesRef.value?.files
if (!files || files.length === 0) {
return
}
uploadFilesToTask(files)
}
function uploadFilesToTask(files: File[] | FileList) {
uploadFiles(attachmentService, task.id, files)
}
const attachmentToDelete = ref<IAttachment | null>(null)
function setAttachmentToDelete(attachment: IAttachment | null) {
attachmentToDelete.value = attachment
}
async function deleteAttachment() {
if (attachmentToDelete.value === null) {
return
}
try {
const r = await attachmentService.delete(attachmentToDelete.value)
attachmentStore.removeById(attachmentToDelete.value.id)
success(r)
setAttachmentToDelete(null)
} catch (e) {
error(e)
}
}
const attachmentImageBlobUrl = ref<string | null>(null)
async function viewOrDownload(attachment: IAttachment) {
if (SUPPORTED_IMAGE_SUFFIX.some((suffix) => attachment.file.name.endsWith(suffix))) {
attachmentImageBlobUrl.value = await attachmentService.getBlobUrl(attachment)
} else {
downloadAttachment(attachment)
}
}
const copy = useCopyToClipboard()
function copyUrl(attachment: IAttachment) {
copy(generateAttachmentUrl(task.id, attachment.id))
}
async function setCoverImage(attachment: IAttachment | null) {
const updatedTask = await taskStore.setCoverImage(task, attachment)
emit('taskChanged', updatedTask)
success({message: t('task.attachment.successfullyChangedCoverImage')})
}
</script>
<style lang="scss" scoped>
.attachments {
input[type=file] {
display: none;
}
@media screen and (max-width: $tablet) {
.button {
width: 100%;
}
}
}
.files {
margin-bottom: 1rem;
}
.attachment {
margin-bottom: .5rem;
display: block;
transition: background-color $transition;
border-radius: $radius;
padding: .5rem;
&:hover {
background-color: var(--grey-200);
}
}
.filename {
font-weight: bold;
margin-bottom: .25rem;
color: var(--text);
}
.info {
color: var(--grey-500);
font-size: .9rem;
p {
margin-bottom: 0;
display: flex;
> span:not(:last-child):after,
> button:not(:last-child):after {
content: '·';
padding: 0 .25rem;
}
}
}
.dropzone {
position: fixed;
background: hsla(var(--grey-100-hsl), 0.8);
top: 0;
left: 0;
bottom: 0;
right: 0;
z-index: 100;
text-align: center;
&.hidden {
display: none;
}
}
.drop-hint {
position: absolute;
bottom: 0;
left: 0;
right: 0;
.icon {
width: 100%;
font-size: 5rem;
height: auto;
text-shadow: var(--shadow-md);
animation: bounce 2s infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
.hint {
margin: .5rem auto 2rem;
border-radius: $radius;
box-shadow: var(--shadow-md);
background: var(--primary);
padding: 1rem;
color: $white; // Should always be white because of the background, regardless of the theme
width: 100%;
max-width: 300px;
}
}
.attachment-info-meta {
display: flex;
align-items: center;
:deep(.user) {
display: flex !important;
align-items: center;
margin: 0 .5rem;
}
@media screen and (max-width: $mobile) {
flex-direction: column;
align-items: flex-start;
:deep(.user) {
margin: .5rem 0;
}
> span:not(:last-child):after,
> button:not(:last-child):after {
display: none;
}
.user .username {
display: none;
}
}
}
.attachment-info-meta-button {
color: var(--link);
}
@keyframes bounce {
from,
20%,
53%,
80%,
to {
animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
transform: translate3d(0, 0, 0);
}
40%,
43% {
animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06);
transform: translate3d(0, -30px, 0);
}
70% {
animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06);
transform: translate3d(0, -15px, 0);
}
90% {
transform: translate3d(0, -4px, 0);
}
}
.is-task-cover {
background: var(--primary);
color: var(--white);
padding: .25rem .35rem;
border-radius: 4px;
font-size: .75rem;
}
</style>

View File

@ -0,0 +1,87 @@
<template>
<span
v-if="checklist.total > 0"
class="checklist-summary"
>
<svg
width="12"
height="12"
>
<circle
stroke-width="2"
fill="transparent"
cx="50%"
cy="50%"
r="5"
/>
<circle
stroke-width="2"
stroke-dasharray="31"
:stroke-dashoffset="checklistCircleDone"
stroke-linecap="round"
fill="transparent"
cx="50%"
cy="50%"
r="5"
/>
</svg>
<span>{{ label }}</span>
</span>
</template>
<script setup lang="ts">
import {computed, type PropType} from 'vue'
import { useI18n } from 'vue-i18n'
import {getChecklistStatistics} from '@/helpers/checklistFromText'
import type {ITask} from '@/modelTypes/ITask'
const props = defineProps({
task: {
type: Object as PropType<ITask>,
required: true,
},
})
const checklist = computed(() => getChecklistStatistics(props.task.description))
const checklistCircleDone = computed(() => {
const r = 5
const c = Math.PI * (r * 2)
const progress = checklist.value.checked / checklist.value.total * 100
return ((100 - progress) / 100) * c
})
const {t} = useI18n({useScope: 'global'})
const label = computed(() => {
return checklist.value.total === checklist.value.checked
? t('task.checklistAllDone', checklist.value)
: t('task.checklistTotal', checklist.value)
})
</script>
<style scoped lang="scss">
.checklist-summary {
color: var(--grey-500);
display: inline-flex;
align-items: center;
padding-left: .5rem;
font-size: .9rem;
}
svg {
transform: rotate(-90deg);
transition: stroke-dashoffset 0.35s;
margin-right: .25rem;
}
circle {
stroke: var(--grey-400);
&:last-child {
stroke: var(--primary);
}
}
</style>

View File

@ -0,0 +1,401 @@
<template>
<div
v-if="enabled"
class="content details"
>
<h3
v-if="canWrite || comments.length > 0"
:class="{'d-print-none': comments.length === 0}"
>
<span class="icon is-grey">
<icon :icon="['far', 'comments']" />
</span>
{{ $t('task.comment.title') }}
</h3>
<div class="comments">
<span
v-if="taskCommentService.loading && saving === null && !creating"
class="is-inline-flex is-align-items-center"
>
<span class="loader is-inline-block mr-2" />
{{ $t('task.comment.loading') }}
</span>
<div
v-for="c in comments"
:key="c.id"
class="media comment"
>
<figure class="media-left is-hidden-mobile">
<img
:src="getAvatarUrl(c.author, 48)"
alt=""
class="image is-avatar"
height="48"
width="48"
>
</figure>
<div class="media-content">
<div class="comment-info">
<img
:src="getAvatarUrl(c.author, 20)"
alt=""
class="image is-avatar d-print-none"
height="20"
width="20"
>
<strong>{{ getDisplayName(c.author) }}</strong>&nbsp;
<span
v-tooltip="formatDateLong(c.created)"
class="has-text-grey"
>
{{ formatDateSince(c.created) }}
</span>
<span
v-if="+new Date(c.created) !== +new Date(c.updated)"
v-tooltip="formatDateLong(c.updated)"
>
· {{ $t('task.comment.edited', {date: formatDateSince(c.updated)}) }}
</span>
<CustomTransition name="fade">
<span
v-if="
taskCommentService.loading &&
saving === c.id
"
class="is-inline-flex"
>
<span class="loader is-inline-block mr-2" />
{{ $t('misc.saving') }}
</span>
<span
v-else-if="
!taskCommentService.loading &&
saved === c.id
"
class="has-text-success"
>
{{ $t('misc.saved') }}
</span>
</CustomTransition>
</div>
<Editor
v-model="c.comment"
:is-edit-enabled="canWrite && c.author.id === currentUserId"
:upload-callback="attachmentUpload"
:upload-enabled="true"
:bottom-actions="actions[c.id]"
:show-save="true"
initial-mode="preview"
@update:modelValue="
() => {
toggleEdit(c)
editCommentWithDelay()
}
"
@save="() => {
toggleEdit(c)
editComment()
}"
/>
</div>
</div>
<div
v-if="canWrite"
class="media comment d-print-none"
>
<figure class="media-left is-hidden-mobile">
<img
:src="userAvatar"
alt=""
class="image is-avatar"
height="48"
width="48"
>
</figure>
<div class="media-content">
<div class="form">
<CustomTransition name="fade">
<span
v-if="taskCommentService.loading && creating"
class="is-inline-flex"
>
<span class="loader is-inline-block mr-2" />
{{ $t('task.comment.creating') }}
</span>
</CustomTransition>
<div class="field">
<Editor
v-if="editorActive"
v-model="newComment.comment"
:class="{
'is-loading':
taskCommentService.loading &&
!isCommentEdit,
}"
:upload-callback="attachmentUpload"
:upload-enabled="true"
:placeholder="$t('task.comment.placeholder')"
@save="addComment()"
/>
</div>
<div class="field">
<x-button
:loading="taskCommentService.loading && !isCommentEdit"
:disabled="newComment.comment === ''"
@click="addComment()"
>
{{ $t('task.comment.comment') }}
</x-button>
</div>
</div>
</div>
</div>
</div>
<modal
:enabled="showDeleteModal"
@close="showDeleteModal = false"
@submit="() => deleteComment(commentToDelete)"
>
<template #header>
<span>{{ $t('task.comment.delete') }}</span>
</template>
<template #text>
<p>
{{ $t('task.comment.deleteText1') }}<br>
<strong class="has-text-white">{{ $t('misc.cannotBeUndone') }}</strong>
</p>
</template>
</modal>
</div>
</template>
<script setup lang="ts">
import {ref, reactive, computed, shallowReactive, watch, nextTick} from 'vue'
import {useI18n} from 'vue-i18n'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import Editor from '@/components/input/AsyncEditor'
import TaskCommentService from '@/services/taskComment'
import TaskCommentModel from '@/models/taskComment'
import type {ITaskComment} from '@/modelTypes/ITaskComment'
import type {ITask} from '@/modelTypes/ITask'
import {uploadFile} from '@/helpers/attachments'
import {success} from '@/message'
import {formatDateLong, formatDateSince} from '@/helpers/time/formatDate'
import {getAvatarUrl, getDisplayName} from '@/models/user'
import {useConfigStore} from '@/stores/config'
import {useAuthStore} from '@/stores/auth'
const props = defineProps({
taskId: {
type: Number,
required: true,
},
canWrite: {
default: true,
},
})
const {t} = useI18n({useScope: 'global'})
const configStore = useConfigStore()
const authStore = useAuthStore()
const comments = ref<ITaskComment[]>([])
const showDeleteModal = ref(false)
const commentToDelete = reactive(new TaskCommentModel())
const isCommentEdit = ref(false)
const commentEdit = reactive(new TaskCommentModel())
const newComment = reactive(new TaskCommentModel())
const saved = ref<ITask['id'] | null>(null)
const saving = ref<ITask['id'] | null>(null)
const userAvatar = computed(() => getAvatarUrl(authStore.info, 48))
const currentUserId = computed(() => authStore.info.id)
const enabled = computed(() => configStore.taskCommentsEnabled)
const actions = computed(() => {
if (!props.canWrite) {
return {}
}
return Object.fromEntries(comments.value.map((comment) => ([
comment.id,
comment.author.id === currentUserId.value
? [{
action: () => toggleDelete(comment.id),
title: t('misc.delete'),
}]
: [],
])))
})
async function attachmentUpload(files: File[] | FileList): (Promise<string[]>) {
const uploadPromises: Promise<string>[] = []
files.forEach((file: File) => {
const promise = new Promise<string>((resolve) => {
uploadFile(props.taskId, file, (uploadedFileUrl: string) => resolve(uploadedFileUrl))
})
uploadPromises.push(promise)
})
return await Promise.all(uploadPromises)
}
const taskCommentService = shallowReactive(new TaskCommentService())
async function loadComments(taskId: ITask['id']) {
if (!enabled.value) {
return
}
newComment.taskId = taskId
commentEdit.taskId = taskId
commentToDelete.taskId = taskId
comments.value = await taskCommentService.getAll({taskId})
}
watch(
() => props.taskId,
loadComments,
{immediate: true},
)
const editorActive = ref(true)
const creating = ref(false)
async function addComment() {
if (newComment.comment === '') {
return
}
// This makes the editor trigger its mounted function again which makes it forget every input
// it currently has in its textarea. This is a counter-hack to a hack inside of vue-easymde
// which made it impossible to detect change from the outside. Therefore the component would
// not update if new content from the outside was made available.
// See https://github.com/NikulinIlya/vue-easymde/issues/3
editorActive.value = false
nextTick(() => (editorActive.value = true))
creating.value = true
try {
const comment = await taskCommentService.create(newComment)
comments.value.push(comment)
newComment.comment = ''
success({message: t('task.comment.addedSuccess')})
} finally {
creating.value = false
}
}
function toggleEdit(comment: ITaskComment) {
isCommentEdit.value = !isCommentEdit.value
Object.assign(commentEdit, comment)
}
function toggleDelete(commentId: ITaskComment['id']) {
showDeleteModal.value = !showDeleteModal.value
commentToDelete.id = commentId
}
const changeTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
async function editCommentWithDelay() {
if (changeTimeout.value !== null) {
clearTimeout(changeTimeout.value)
}
changeTimeout.value = setTimeout(async () => {
await editComment()
}, 5000)
}
async function editComment() {
if (commentEdit.comment === '') {
return
}
if (changeTimeout.value !== null) {
clearTimeout(changeTimeout.value)
}
saving.value = commentEdit.id
commentEdit.taskId = props.taskId
try {
const comment = await taskCommentService.update(commentEdit)
for (const c in comments.value) {
if (comments.value[c].id === commentEdit.id) {
comments.value[c] = comment
}
}
saved.value = commentEdit.id
setTimeout(() => {
saved.value = null
}, 2000)
} finally {
isCommentEdit.value = false
saving.value = null
}
}
async function deleteComment(commentToDelete: ITaskComment) {
try {
await taskCommentService.delete(commentToDelete)
const index = comments.value.findIndex(({id}) => id === commentToDelete.id)
comments.value.splice(index, 1)
success({message: t('task.comment.deleteSuccess')})
} finally {
showDeleteModal.value = false
}
}
</script>
<style lang="scss" scoped>
.media-left {
margin: 0 1rem !important;
}
.comment-info {
display: flex;
align-items: center;
gap: .5rem;
img {
@media screen and (max-width: $tablet) {
display: block;
width: 20px;
height: 20px;
padding-right: 0;
margin-right: .5rem;
}
@media screen and (min-width: $tablet) {
display: none;
}
}
span {
font-size: .75rem;
line-height: 1;
}
}
.image.is-avatar {
border-radius: 100%;
}
.media-content {
width: calc(100% - 48px - 2rem);
}
</style>

View File

@ -0,0 +1,74 @@
<template>
<p class="created">
<time
v-tooltip="formatDateLong(task.created)"
:datetime="formatISO(task.created)"
>
<i18n-t
keypath="task.detail.created"
scope="global"
>
<span>{{ formatDateSince(task.created) }}</span>
{{ getDisplayName(task.createdBy) }}
</i18n-t>
</time>
<template v-if="+new Date(task.created) !== +new Date(task.updated)">
<br>
<!-- Computed properties to show the actual date every time it gets updated -->
<time
v-tooltip="updatedFormatted"
:datetime="formatISO(task.updated)"
>
<i18n-t
keypath="task.detail.updated"
scope="global"
>
<span>{{ updatedSince }}</span>
</i18n-t>
</time>
</template>
<template v-if="task.done">
<br>
<time
v-tooltip="doneFormatted"
:datetime="formatISO(task.doneAt)"
>
<i18n-t
keypath="task.detail.doneAt"
scope="global"
>
<span>{{ doneSince }}</span>
</i18n-t>
</time>
</template>
</p>
</template>
<script lang="ts" setup>
import {computed, toRefs, type PropType} from 'vue'
import type {ITask} from '@/modelTypes/ITask'
import {formatISO, formatDateLong, formatDateSince} from '@/helpers/time/formatDate'
import {getDisplayName} from '@/models/user'
const props = defineProps({
task: {
type: Object as PropType<ITask>,
required: true,
},
})
const {task} = toRefs(props)
const updatedSince = computed(() => formatDateSince(task.value.updated))
const updatedFormatted = computed(() => formatDateLong(task.value.updated))
const doneSince = computed(() => formatDateSince(task.value.doneAt))
const doneFormatted = computed(() => formatDateLong(task.value.doneAt))
</script>
<style lang="scss" scoped>
.created {
font-size: .75rem;
color: var(--grey-500);
text-align: right;
}
</style>

View File

@ -0,0 +1,18 @@
<template>
<td v-tooltip="+date === 0 ? '' : formatDateLong(date)">
<time :datetime="date ? formatISO(date) : undefined">
{{ +date === 0 ? '-' : formatDateSince(date) }}
</time>
</td>
</template>
<script setup lang="ts">
import {formatISO, formatDateLong, formatDateSince} from '@/helpers/time/formatDate'
defineProps({
date: {
type: Date,
default: 0,
},
})
</script>

View File

@ -0,0 +1,190 @@
<template>
<div
:class="{ 'is-loading': taskService.loading }"
class="defer-task loading-container"
>
<label class="label">{{ $t('task.deferDueDate.title') }}</label>
<div class="defer-days">
<x-button
:shadow="false"
variant="secondary"
@click.prevent.stop="() => deferDays(1)"
>
{{ $t('task.deferDueDate.1day') }}
</x-button>
<x-button
:shadow="false"
variant="secondary"
@click.prevent.stop="() => deferDays(3)"
>
{{ $t('task.deferDueDate.3days') }}
</x-button>
<x-button
:shadow="false"
variant="secondary"
@click.prevent.stop="() => deferDays(7)"
>
{{ $t('task.deferDueDate.1week') }}
</x-button>
</div>
<flat-pickr
v-model="dueDate"
:class="{ disabled: taskService.loading }"
:config="flatPickerConfig"
:disabled="taskService.loading || undefined"
class="input"
/>
</div>
</template>
<script setup lang="ts">
import {ref, shallowReactive, computed, watch, onMounted, onBeforeUnmount} from 'vue'
import {useI18n} from 'vue-i18n'
import flatPickr from 'vue-flatpickr-component'
import TaskService from '@/services/task'
import type {ITask} from '@/modelTypes/ITask'
import { getFlatpickrLanguage } from '@/helpers/flatpickrLanguage'
const {
modelValue,
} = defineProps<{
modelValue: ITask,
}>()
const emit = defineEmits(['update:modelValue'])
const {t} = useI18n({useScope: 'global'})
const taskService = shallowReactive(new TaskService())
const task = ref<ITask>()
// We're saving the due date seperately to prevent null errors in very short periods where the task is null.
const dueDate = ref<Date | null>()
const lastValue = ref<Date | null>()
const changeInterval = ref<ReturnType<typeof setInterval>>()
watch(
() => modelValue,
(value) => {
task.value = { ...value }
dueDate.value = value.dueDate
lastValue.value = value.dueDate
},
{immediate: true},
)
onMounted(() => {
// Because we don't really have other ways of handling change since if we let flatpickr
// change events trigger updates, it would trigger a flatpickr change event which would trigger
// an update which would trigger a change event and so on...
// This is either a bug in flatpickr or in the vue component of it.
// To work around that, we're only updating if something changed and check each second and when closing the popup.
if (changeInterval.value) {
clearInterval(changeInterval.value)
}
changeInterval.value = setInterval(updateDueDate, 1000)
})
onBeforeUnmount(() => {
if (changeInterval.value) {
clearInterval(changeInterval.value)
}
updateDueDate()
})
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
inline: true,
locale: getFlatpickrLanguage(),
}))
function deferDays(days: number) {
dueDate.value = new Date(dueDate.value)
const currentDate = new Date(dueDate.value).getDate()
dueDate.value = new Date(dueDate.value).setDate(currentDate + days)
updateDueDate()
}
async function updateDueDate() {
if (!dueDate.value) {
return
}
if (+new Date(dueDate.value) === +lastValue.value) {
return
}
const newTask = await taskService.update({
...task.value,
dueDate: new Date(dueDate.value),
})
lastValue.value = newTask.dueDate
task.value = newTask
emit('update:modelValue', newTask)
}
</script>
<style lang="scss" scoped>
// 100px is roughly the size the pane is pulled to the right
$defer-task-max-width: 350px + 100px;
.defer-task {
position: absolute;
width: 100%;
max-width: $defer-task-max-width;
border-radius: $radius;
border: 1px solid var(--grey-200);
padding: 1rem;
margin: 1rem;
background: var(--white);
color: var(--text);
cursor: default;
z-index: 10;
box-shadow: var(--shadow-lg);
@media screen and (max-width: ($defer-task-max-width)) {
left: .5rem;
right: .5rem;
max-width: 100%;
width: calc(100vw - 1rem - 2rem);
}
}
.defer-days {
justify-content: space-between;
display: flex;
margin: .5rem 0;
}
:deep() {
input.input {
display: none;
}
.flatpickr-calendar {
margin: 0 auto;
box-shadow: none;
@media screen and (max-width: ($defer-task-max-width)) {
max-width: 100%;
}
span {
width: auto !important;
}
}
.flatpickr-innerContainer {
@media screen and (max-width: ($defer-task-max-width)) {
overflow: scroll;
}
}
}
</style>

View File

@ -0,0 +1,137 @@
<template>
<div>
<h3>
<span class="icon is-grey">
<icon icon="align-left" />
</span>
{{ $t('task.attributes.description') }}
<CustomTransition name="fade">
<span
v-if="loading && saving"
class="is-small is-inline-flex"
>
<span class="loader is-inline-block mr-2" />
{{ $t('misc.saving') }}
</span>
<span
v-else-if="!loading && saved"
class="is-small has-text-success"
>
<icon icon="check" />
{{ $t('misc.saved') }}
</span>
</CustomTransition>
</h3>
<Editor
v-model="description"
class="tiptap__task-description"
:is-edit-enabled="canWrite"
:upload-callback="uploadCallback"
:placeholder="$t('task.description.placeholder')"
:show-save="true"
edit-shortcut="e"
@update:modelValue="saveWithDelay"
@save="save"
/>
</div>
</template>
<script setup lang="ts">
import {ref, computed, watch} from 'vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import Editor from '@/components/input/AsyncEditor'
import type {ITask} from '@/modelTypes/ITask'
import {useTaskStore} from '@/stores/tasks'
type AttachmentUploadFunction = (file: File, onSuccess: (attachmentUrl: string) => void) => Promise<string>
const {
modelValue,
attachmentUpload,
canWrite,
} = defineProps<{
modelValue: ITask,
attachmentUpload: AttachmentUploadFunction,
canWrite: boolean,
}>()
const emit = defineEmits(['update:modelValue'])
const description = ref<string>('')
const saved = ref(false)
// Since loading is global state, this variable ensures we're only showing the saving icon when saving the description.
const saving = ref(false)
const taskStore = useTaskStore()
const loading = computed(() => taskStore.isLoading)
watch(
() => modelValue.description,
value => {
description.value = value
},
{immediate: true},
)
const changeTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
async function saveWithDelay() {
if (changeTimeout.value !== null) {
clearTimeout(changeTimeout.value)
}
changeTimeout.value = setTimeout(async () => {
await save()
}, 5000)
}
async function save() {
if (changeTimeout.value !== null) {
clearTimeout(changeTimeout.value)
}
saving.value = true
try {
// FIXME: don't update state from internal.
const updated = await taskStore.update({
...modelValue,
description: description.value,
})
emit('update:modelValue', updated)
saved.value = true
setTimeout(() => {
saved.value = false
}, 2000)
} finally {
saving.value = false
}
}
async function uploadCallback(files: File[] | FileList): (Promise<string[]>) {
const uploadPromises: Promise<string>[] = []
files.forEach((file: File) => {
const promise = new Promise<string>((resolve) => {
attachmentUpload(file, (uploadedFileUrl: string) => resolve(uploadedFileUrl))
})
uploadPromises.push(promise)
})
return await Promise.all(uploadPromises)
}
</script>
<style lang="scss" scoped>
.tiptap__task-description {
// The exact amount of pixels we need to make the description icon align with the buttons and the form inside the editor.
// The icon is not exactly the same length on all sides so we need to hack our way around it.
margin-left: 4px;
}
</style>

View File

@ -0,0 +1,133 @@
<template>
<Multiselect
v-model="assignees"
class="edit-assignees"
:class="{'has-assignees': assignees.length > 0}"
:loading="projectUserService.loading"
:placeholder="$t('task.assignee.placeholder')"
:multiple="true"
:search-results="foundUsers"
label="name"
:select-placeholder="$t('task.assignee.selectPlaceholder')"
:autocomplete-enabled="false"
@search="findUser"
@select="addAssignee"
>
<template #items="{items}">
<AssigneeList
:assignees="items"
:remove="removeAssignee"
:disabled="disabled"
/>
</template>
<template #searchResult="{option: user}">
<User
:avatar-size="24"
:show-username="true"
:user="user"
/>
</template>
</Multiselect>
</template>
<script setup lang="ts">
import {ref, shallowReactive, watch, nextTick, type PropType} from 'vue'
import {useI18n} from 'vue-i18n'
import User from '@/components/misc/user.vue'
import Multiselect from '@/components/input/multiselect.vue'
import {includesById} from '@/helpers/utils'
import ProjectUserService from '@/services/projectUsers'
import {success} from '@/message'
import {useTaskStore} from '@/stores/tasks'
import type {IUser} from '@/modelTypes/IUser'
import {getDisplayName} from '@/models/user'
import AssigneeList from '@/components/tasks/partials/assigneeList.vue'
const props = defineProps({
taskId: {
type: Number,
required: true,
},
projectId: {
type: Number,
required: true,
},
disabled: {
default: false,
},
modelValue: {
type: Array as PropType<IUser[]>,
default: () => [],
},
})
const emit = defineEmits(['update:modelValue'])
const taskStore = useTaskStore()
const {t} = useI18n({useScope: 'global'})
const projectUserService = shallowReactive(new ProjectUserService())
const foundUsers = ref<IUser[]>([])
const assignees = ref<IUser[]>([])
let isAdding = false
watch(
() => props.modelValue,
(value) => {
assignees.value = value
},
{
immediate: true,
deep: true,
},
)
async function addAssignee(user: IUser) {
if (isAdding) {
return
}
try {
nextTick(() => isAdding = true)
await taskStore.addAssignee({user: user, taskId: props.taskId})
emit('update:modelValue', assignees.value)
success({message: t('task.assignee.assignSuccess')})
} finally {
nextTick(() => isAdding = false)
}
}
async function removeAssignee(user: IUser) {
await taskStore.removeAssignee({user: user, taskId: props.taskId})
// Remove the assignee from the project
for (const a in assignees.value) {
if (assignees.value[a].id === user.id) {
assignees.value.splice(a, 1)
}
}
success({message: t('task.assignee.unassignSuccess')})
}
async function findUser(query: string) {
const response = await projectUserService.getAll({projectId: props.projectId}, {s: query}) as IUser[]
// Filter the results to not include users who are already assigned
foundUsers.value = response
.filter(({id}) => !includesById(assignees.value, id))
.map(u => {
// Users may not have a display name set, so we fall back on the username in that case
u.name = getDisplayName(u)
return u
})
}
</script>
<style lang="scss">
.edit-assignees.has-assignees.multiselect .input {
padding-left: 0;
}
</style>

View File

@ -0,0 +1,169 @@
<template>
<Multiselect
v-model="labels"
:loading="loading"
:placeholder="$t('task.label.placeholder')"
:multiple="true"
:search-results="foundLabels"
label="title"
:creatable="creatable"
:create-placeholder="$t('task.label.createPlaceholder')"
:search-delay="10"
:close-after-select="false"
@search="findLabel"
@select="addLabel"
@create="createAndAddLabel"
>
<template #tag="{item: label}">
<span
:style="{'background': label.hexColor, 'color': label.textColor}"
class="tag"
>
<span>{{ label.title }}</span>
<BaseButton
v-cy="'taskDetail.removeLabel'"
class="delete is-small"
@click="removeLabel(label)"
/>
</span>
</template>
<template #searchResult="{option}">
<span
v-if="typeof option === 'string'"
class="tag search-result"
>
<span>{{ option }}</span>
</span>
<span
v-else
:style="{'background': option.hexColor, 'color': option.textColor}"
class="tag search-result"
>
<span>{{ option.title }}</span>
</span>
</template>
</Multiselect>
</template>
<script setup lang="ts">
import {type PropType, ref, computed, shallowReactive, watch} from 'vue'
import {useI18n} from 'vue-i18n'
import LabelModel from '@/models/label'
import LabelTaskService from '@/services/labelTask'
import {success} from '@/message'
import BaseButton from '@/components/base/BaseButton.vue'
import Multiselect from '@/components/input/multiselect.vue'
import type {ILabel} from '@/modelTypes/ILabel'
import {useLabelStore} from '@/stores/labels'
import {useTaskStore} from '@/stores/tasks'
import {getRandomColorHex} from '@/helpers/color/randomColor'
const props = defineProps({
modelValue: {
type: Array as PropType<ILabel[]>,
default: () => [],
},
taskId: {
type: Number,
required: false,
default: 0,
},
disabled: {
default: false,
},
creatable: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['update:modelValue'])
const {t} = useI18n({useScope: 'global'})
const labelTaskService = shallowReactive(new LabelTaskService())
const labels = ref<ILabel[]>([])
const query = ref('')
watch(
() => props.modelValue,
(value) => {
labels.value = value
},
{
immediate: true,
deep: true,
},
)
const taskStore = useTaskStore()
const labelStore = useLabelStore()
const foundLabels = computed(() => labelStore.filterLabelsByQuery(labels.value, query.value))
const loading = computed(() => labelTaskService.loading || labelStore.isLoading)
function findLabel(newQuery: string) {
query.value = newQuery
}
async function addLabel(label: ILabel, showNotification = true) {
if (props.taskId === 0) {
emit('update:modelValue', labels.value)
return
}
await taskStore.addLabel({label, taskId: props.taskId})
emit('update:modelValue', labels.value)
if (showNotification) {
success({message: t('task.label.addSuccess')})
}
}
async function removeLabel(label: ILabel) {
if (props.taskId !== 0) {
await taskStore.removeLabel({label, taskId: props.taskId})
}
for (const l in labels.value) {
if (labels.value[l].id === label.id) {
labels.value.splice(l, 1) // FIXME: l should be index
}
}
emit('update:modelValue', labels.value)
success({message: t('task.label.removeSuccess')})
}
async function createAndAddLabel(title: string) {
if (props.taskId === 0) {
return
}
const newLabel = await labelStore.createLabel(new LabelModel({
title,
hexColor: getRandomColorHex(),
}))
addLabel(newLabel, false)
labels.value.push(newLabel)
success({message: t('task.label.addCreateSuccess')})
}
</script>
<style lang="scss" scoped>
.tag {
margin: .25rem !important;
}
.tag.search-result {
margin: 0 !important;
}
:deep(.input-wrapper) {
padding: .25rem !important;
}
:deep(input.input) {
padding: 0 .5rem;
}
</style>

View File

@ -0,0 +1,166 @@
<template>
<div class="heading">
<div class="flex is-align-items-center">
<BaseButton @click="copyUrl">
<h1 class="title task-id">
{{ textIdentifier }}
</h1>
</BaseButton>
<Done
class="heading__done"
:is-done="task.done"
/>
<ColorBubble
v-if="task.hexColor !== ''"
:color="getHexColor(task.hexColor)"
class="ml-2"
/>
</div>
<h1
class="title input"
:class="{'disabled': !canWrite}"
:contenteditable="canWrite ? true : undefined"
:spellcheck="false"
@blur="save(($event.target as HTMLInputElement).textContent as string)"
@keydown.enter.prevent.stop="($event.target as HTMLInputElement).blur()"
>
{{ task.title.trim() }}
</h1>
<CustomTransition name="fade">
<span
v-if="loading && saving"
class="is-inline-flex is-align-items-center"
>
<span class="loader is-inline-block mr-2" />
{{ $t('misc.saving') }}
</span>
<span
v-else-if="!loading && showSavedMessage"
class="has-text-success is-inline-flex is-align-content-center"
>
<icon
icon="check"
class="mr-2"
/>
{{ $t('misc.saved') }}
</span>
</CustomTransition>
</div>
</template>
<script setup lang="ts">
import {ref, computed, type PropType} from 'vue'
import {useRouter} from 'vue-router'
import BaseButton from '@/components/base/BaseButton.vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import ColorBubble from '@/components/misc/colorBubble.vue'
import Done from '@/components/misc/Done.vue'
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {useTaskStore} from '@/stores/tasks'
import type {ITask} from '@/modelTypes/ITask'
import {getHexColor, getTaskIdentifier} from '@/models/task'
const props = defineProps({
task: {
type: Object as PropType<ITask>,
required: true,
},
canWrite: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:task'])
const router = useRouter()
const copy = useCopyToClipboard()
async function copyUrl() {
const route = router.resolve({name: 'task.detail', query: {taskId: props.task.id}})
const absoluteURL = new URL(route.href, window.location.href).href
await copy(absoluteURL)
}
const taskStore = useTaskStore()
const loading = computed(() => taskStore.isLoading)
const textIdentifier = computed(() => getTaskIdentifier(props.task))
// Since loading is global state, this variable ensures we're only showing the saving icon when saving the description.
const saving = ref(false)
const showSavedMessage = ref(false)
async function save(title: string) {
// We only want to save if the title was actually changed.
// so we only continue if the task title changed.
if (title === props.task.title) {
return
}
try {
saving.value = true
const newTask = await taskStore.update({
...props.task,
title,
})
emit('update:task', newTask)
showSavedMessage.value = true
setTimeout(() => {
showSavedMessage.value = false
}, 2000)
} finally {
saving.value = false
}
}
</script>
<style lang="scss" scoped>
.heading {
display: flex;
justify-content: flex-start;
text-transform: none;
align-items: center;
@media screen and (max-width: $tablet) {
flex-direction: column;
align-items: start;
}
}
.title {
margin-bottom: 0;
}
.title.input {
// 1.8rem is the font-size, 1.125 is the line-height, .3rem padding everywhere, 1px border around the whole thing.
min-height: calc(1.8rem * 1.125 + .6rem + 2px);
@media screen and (max-width: $tablet) {
margin: 0 -.3rem .5rem -.3rem; // the title has 0.3rem padding - this make the text inside of it align with the rest
}
@media screen and (min-width: $tablet) and (max-width: #{$desktop + $close-button-min-space}) {
width: calc(100% - 6.5rem);
}
}
.title.task-id {
color: var(--grey-400);
white-space: nowrap;
}
.heading__done {
margin-left: .5rem;
}
.color-bubble {
height: .75rem;
width: .75rem;
}
</style>

View File

@ -0,0 +1,354 @@
<template>
<div
class="task loader-container draggable"
:class="{
'is-loading': loadingInternal || loading,
'draggable': !(loadingInternal || loading),
'has-light-text': !colorIsDark(color),
'has-custom-background-color': color ?? undefined,
}"
:style="{'background-color': color ?? undefined}"
@click.exact="openTaskDetail()"
@click.ctrl="() => toggleTaskDone(task)"
@click.meta="() => toggleTaskDone(task)"
>
<img
v-if="coverImageBlobUrl"
:src="coverImageBlobUrl"
alt=""
class="cover-image"
>
<div class="p-2">
<span class="task-id">
<Done
class="kanban-card__done"
:is-done="task.done"
variant="small"
/>
<template v-if="task.identifier === ''">
#{{ task.index }}
</template>
<template v-else>
{{ task.identifier }}
</template>
</span>
<span
v-if="task.dueDate > 0"
v-tooltip="formatDateLong(task.dueDate)"
:class="{'overdue': task.dueDate <= new Date() && !task.done}"
class="due-date"
>
<span class="icon">
<icon :icon="['far', 'calendar-alt']" />
</span>
<time :datetime="formatISO(task.dueDate)">
{{ formatDateSince(task.dueDate) }}
</time>
</span>
<h3>{{ task.title }}</h3>
<ProgressBar
v-if="task.percentDone > 0"
class="task-progress"
:value="task.percentDone * 100"
/>
<div class="footer">
<Labels :labels="task.labels" />
<PriorityLabel
:priority="task.priority"
:done="task.done"
class="is-inline-flex is-align-items-center"
/>
<AssigneeList
v-if="task.assignees.length > 0"
:assignees="task.assignees"
:avatar-size="24"
class="mr-1"
/>
<ChecklistSummary
:task="task"
class="checklist"
/>
<span
v-if="task.attachments.length > 0"
class="icon"
>
<icon icon="paperclip" />
</span>
<span
v-if="!isEditorContentEmpty(task.description)"
class="icon"
>
<icon icon="align-left" />
</span>
<span
v-if="task.repeatAfter.amount > 0"
class="icon"
>
<icon icon="history" />
</span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {ref, computed, watch} from 'vue'
import {useRouter} from 'vue-router'
import PriorityLabel from '@/components/tasks/partials/priorityLabel.vue'
import ProgressBar from '@/components/misc/ProgressBar.vue'
import Done from '@/components/misc/Done.vue'
import Labels from '@/components/tasks/partials/labels.vue'
import ChecklistSummary from './checklist-summary.vue'
import {getHexColor} from '@/models/task'
import type {ITask} from '@/modelTypes/ITask'
import {SUPPORTED_IMAGE_SUFFIX} from '@/models/attachment'
import AttachmentService from '@/services/attachment'
import {formatDateLong, formatISO, formatDateSince} from '@/helpers/time/formatDate'
import {colorIsDark} from '@/helpers/color/colorIsDark'
import {useTaskStore} from '@/stores/tasks'
import AssigneeList from '@/components/tasks/partials/assigneeList.vue'
import {useAuthStore} from '@/stores/auth'
import {playPopSound} from '@/helpers/playPop'
import {isEditorContentEmpty} from '@/helpers/editorContentEmpty'
const {
task,
loading = false,
} = defineProps<{
task: ITask,
loading: boolean,
}>()
const router = useRouter()
const loadingInternal = ref(false)
const color = computed(() => getHexColor(task.hexColor))
async function toggleTaskDone(task: ITask) {
loadingInternal.value = true
try {
const updatedTask = await useTaskStore().update({
...task,
done: !task.done,
})
if (updatedTask.done && useAuthStore().settings.frontendSettings.playSoundWhenDone) {
playPopSound()
}
} finally {
loadingInternal.value = false
}
}
function openTaskDetail() {
router.push({
name: 'task.detail',
params: {id: task.id},
state: {backdropView: router.currentRoute.value.fullPath},
})
}
const coverImageBlobUrl = ref<string | null>(null)
async function maybeDownloadCoverImage() {
if (!task.coverImageAttachmentId) {
coverImageBlobUrl.value = null
return
}
const attachment = task.attachments.find(a => a.id === task.coverImageAttachmentId)
if (!attachment || !SUPPORTED_IMAGE_SUFFIX.some((suffix) => attachment.file.name.endsWith(suffix))) {
return
}
const attachmentService = new AttachmentService()
coverImageBlobUrl.value = await attachmentService.getBlobUrl(attachment)
}
watch(
() => task.coverImageAttachmentId,
maybeDownloadCoverImage,
{immediate: true},
)
</script>
<style lang="scss" scoped>
$task-background: var(--white);
.task {
-webkit-touch-callout: none; // iOS Safari
user-select: none;
cursor: pointer;
box-shadow: var(--shadow-xs);
display: block;
font-size: .9rem;
border-radius: $radius;
background: $task-background;
overflow: hidden;
&.loader-container.is-loading::after {
width: 1.5rem;
height: 1.5rem;
top: calc(50% - .75rem);
left: calc(50% - .75rem);
border-width: 2px;
}
h3 {
font-family: $family-sans-serif;
font-size: .85rem;
word-break: break-word;
}
.due-date {
float: right;
display: flex;
align-items: center;
.icon {
margin-right: .25rem;
}
&.overdue {
color: var(--danger);
}
}
.label-wrapper .tag {
margin: .5rem .5rem 0 0;
}
.footer {
background: transparent;
padding: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
margin-top: .25rem;
:deep(.tag),
:deep(.checklist-summary),
.assignees,
.icon,
.priority-label {
margin-right: .25rem;
}
:deep(.checklist-summary) {
padding-left: 0;
}
.assignees {
display: flex;
.user {
display: inline;
margin: 0;
img {
margin: 0;
}
}
}
// FIXME: should be in labels.vue
:deep(.tag) {
margin-left: 0;
}
.priority-label {
font-size: .75rem;
padding: 0 .5rem 0 .25rem;
.icon {
height: 1rem;
padding: 0 .25rem;
margin-top: 0;
}
}
}
.footer .icon,
.due-date,
.priority-label {
background: var(--grey-100);
border-radius: $radius;
padding: 0 .5rem;
}
.due-date {
padding: 0 .25rem;
}
.task-id {
color: var(--grey-500);
font-size: .8rem;
margin-bottom: .25rem;
display: flex;
}
&.is-moving {
opacity: .5;
}
span {
width: auto;
}
&.has-custom-background-color {
color: hsl(215, 27.9%, 16.9%); // copied from grey-800 to avoid different values in dark mode
.footer .icon,
.due-date,
.priority-label {
background: hsl(220, 13%, 91%);
}
.footer :deep(.checklist-summary) {
color: hsl(216.9, 19.1%, 26.7%); // grey-700
}
}
&.has-light-text {
--white: hsla(var(--white-h), var(--white-s), var(--white-l), var(--white-a)) !important;
color: var(--white);
.task-id {
color: hsl(220, 13%, 91%); // grey-200;
}
.footer .icon,
.due-date,
.priority-label {
background: hsl(215, 27.9%, 16.9%); // grey-800
}
.footer {
.icon svg {
fill: var(--white);
}
:deep(.checklist-summary) {
color: hsl(220, 13%, 91%); // grey-200
}
}
}
}
.kanban-card__done {
margin-right: .25rem;
}
.task-progress {
margin: 8px 0 0 0;
width: 100%;
height: 0.5rem;
}
</style>

View File

@ -0,0 +1,25 @@
<script setup lang="ts">
import type {ILabel} from '@/modelTypes/ILabel'
defineProps<{
label: ILabel
}>()
</script>
<template>
<span
:key="label.id"
:style="{'background': label.hexColor, 'color': label.textColor}"
class="tag"
>
<span>{{ label.title }}</span>
</span>
</template>
<style scoped lang="scss">
.tag {
& + & {
margin-left: 0.5rem;
}
}
</style>

View File

@ -0,0 +1,33 @@
<template>
<div class="label-wrapper">
<XLabel
v-for="label in labels"
:key="label.id"
:label="label"
/>
</div>
</template>
<script setup lang="ts">
import type {PropType} from 'vue'
import type {ILabel} from '@/modelTypes/ILabel'
import XLabel from '@/components/tasks/partials/label.vue'
defineProps({
labels: {
type: Array as PropType<ILabel[]>,
required: true,
},
})
</script>
<style lang="scss" scoped>
.label-wrapper {
display: inline;
:deep(.tag) {
margin-bottom: .25rem;
}
}
</style>

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