chore: move frontend files
This commit is contained in:
72
frontend/src/composables/useAutoHeightTextarea.ts
Normal file
72
frontend/src/composables/useAutoHeightTextarea.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import {ref, unref, watch} from 'vue'
|
||||
import {debouncedWatch, tryOnMounted, useWindowSize, type MaybeRef} from '@vueuse/core'
|
||||
|
||||
// TODO: also add related styles
|
||||
// OR: replace with vueuse function
|
||||
export function useAutoHeightTextarea(value: MaybeRef<string>) {
|
||||
const textarea = ref<HTMLTextAreaElement | null>(null)
|
||||
const minHeight = ref(0)
|
||||
|
||||
// adapted from https://github.com/LeaVerou/stretchy/blob/47f5f065c733029acccb755cae793009645809e2/src/stretchy.js#L34
|
||||
function resize(textareaEl: HTMLTextAreaElement | null) {
|
||||
if (!textareaEl) return
|
||||
|
||||
let empty
|
||||
|
||||
// the value here is the attribute value
|
||||
if (!textareaEl.value && textareaEl.placeholder) {
|
||||
empty = true
|
||||
textareaEl.value = textareaEl.placeholder
|
||||
}
|
||||
|
||||
const cs = getComputedStyle(textareaEl)
|
||||
|
||||
textareaEl.style.minHeight = ''
|
||||
textareaEl.style.height = '0'
|
||||
const offset = textareaEl.offsetHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)
|
||||
const height = textareaEl.scrollHeight + offset + 'px'
|
||||
|
||||
textareaEl.style.height = height
|
||||
|
||||
// calculate min-height for the first time
|
||||
if (!minHeight.value) {
|
||||
minHeight.value = parseFloat(height)
|
||||
}
|
||||
|
||||
textareaEl.style.minHeight = minHeight.value.toString()
|
||||
|
||||
|
||||
if (empty) {
|
||||
textareaEl.value = ''
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
tryOnMounted(() => {
|
||||
if (textarea.value) {
|
||||
// we don't want scrollbars
|
||||
textarea.value.style.overflowY = 'hidden'
|
||||
}
|
||||
})
|
||||
|
||||
const {width: windowWidth} = useWindowSize()
|
||||
|
||||
debouncedWatch(
|
||||
windowWidth,
|
||||
() => resize(textarea.value),
|
||||
{debounce: 200},
|
||||
)
|
||||
|
||||
// It is not possible to get notified of a change of the value attribute of a textarea without workarounds (setTimeout)
|
||||
// So instead we watch the value that we bound to it.
|
||||
watch(
|
||||
() => [textarea.value, unref(value)],
|
||||
() => resize(textarea.value),
|
||||
{
|
||||
immediate: true, // calculate initial size
|
||||
flush: 'post', // resize after value change is rendered to DOM
|
||||
},
|
||||
)
|
||||
|
||||
return textarea
|
||||
}
|
16
frontend/src/composables/useBodyClass.ts
Normal file
16
frontend/src/composables/useBodyClass.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import {ref, watchEffect} from 'vue'
|
||||
import {tryOnBeforeUnmount} from '@vueuse/core'
|
||||
|
||||
export function useBodyClass(className: string, defaultValue = false) {
|
||||
const isActive = ref(defaultValue)
|
||||
|
||||
watchEffect(() => {
|
||||
isActive.value
|
||||
? document.body.classList.add(className)
|
||||
: document.body.classList.remove(className)
|
||||
})
|
||||
|
||||
tryOnBeforeUnmount(() => isActive.value && document.body.classList.remove(className))
|
||||
|
||||
return isActive
|
||||
}
|
49
frontend/src/composables/useColorScheme.ts
Normal file
49
frontend/src/composables/useColorScheme.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import {computed, watch, readonly} from 'vue'
|
||||
import {createSharedComposable, usePreferredColorScheme, tryOnMounted} from '@vueuse/core'
|
||||
import type {BasicColorSchema} from '@vueuse/core'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
|
||||
const DEFAULT_COLOR_SCHEME_SETTING: BasicColorSchema = 'light'
|
||||
|
||||
const CLASS_DARK = 'dark'
|
||||
const CLASS_LIGHT = 'light'
|
||||
|
||||
// This is built upon the vueuse useDark
|
||||
// Main differences:
|
||||
// - usePreferredColorScheme
|
||||
// - doesn't allow setting via the `isDark` ref.
|
||||
// - instead the store is exposed
|
||||
// - value is synced via `createSharedComposable`
|
||||
// https://github.com/vueuse/vueuse/blob/main/packages/core/useDark/index.ts
|
||||
export const useColorScheme = createSharedComposable(() => {
|
||||
const authStore = useAuthStore()
|
||||
const store = computed(() => authStore.settings.frontendSettings.colorSchema)
|
||||
|
||||
const preferredColorScheme = usePreferredColorScheme()
|
||||
|
||||
const isDark = computed<boolean>(() => {
|
||||
if (store.value !== 'auto') {
|
||||
return store.value === 'dark'
|
||||
}
|
||||
|
||||
const autoColorScheme = preferredColorScheme.value === 'no-preference'
|
||||
? DEFAULT_COLOR_SCHEME_SETTING
|
||||
: preferredColorScheme.value
|
||||
return autoColorScheme === 'dark'
|
||||
})
|
||||
|
||||
function onChanged(v: boolean) {
|
||||
const el = window?.document.querySelector('html')
|
||||
el?.classList.toggle(CLASS_DARK, v)
|
||||
el?.classList.toggle(CLASS_LIGHT, !v)
|
||||
}
|
||||
|
||||
watch(isDark, onChanged, { flush: 'post' })
|
||||
|
||||
tryOnMounted(() => onChanged(isDark.value))
|
||||
|
||||
return {
|
||||
store,
|
||||
isDark: readonly(isDark),
|
||||
}
|
||||
})
|
45
frontend/src/composables/useCopyToClipboard.ts
Normal file
45
frontend/src/composables/useCopyToClipboard.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import {error} from '@/message'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
export function useCopyToClipboard() {
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
function fallbackCopyTextToClipboard(text: string) {
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = text
|
||||
|
||||
// Avoid scrolling to bottom
|
||||
textArea.style.top = '0'
|
||||
textArea.style.left = '0'
|
||||
textArea.style.position = 'fixed'
|
||||
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
|
||||
try {
|
||||
// NOTE: the execCommand is deprecated but as of 2022_09
|
||||
// widely supported and works without https
|
||||
const successful = document.execCommand('copy')
|
||||
if (!successful) {
|
||||
throw new Error()
|
||||
}
|
||||
} catch (err) {
|
||||
error(t('misc.copyError'))
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
|
||||
return async (text: string) => {
|
||||
if (!navigator.clipboard) {
|
||||
fallbackCopyTextToClipboard(text)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} catch(e) {
|
||||
error(t('misc.copyError'))
|
||||
}
|
||||
}
|
||||
}
|
26
frontend/src/composables/useDaytimeSalutation.ts
Normal file
26
frontend/src/composables/useDaytimeSalutation.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import {computed, onActivated, ref} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {hourToDaytime} from '@/helpers/hourToDaytime'
|
||||
|
||||
export type Daytime = 'night' | 'morning' | 'day' | 'evening'
|
||||
|
||||
export function useDaytimeSalutation() {
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const now = ref(new Date())
|
||||
onActivated(() => now.value = new Date())
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const name = computed(() => authStore.userDisplayName)
|
||||
const daytime = computed(() => hourToDaytime(now.value))
|
||||
|
||||
const salutations = {
|
||||
'night': () => t('home.welcomeNight', {username: name.value}),
|
||||
'morning': () => t('home.welcomeMorning', {username: name.value}),
|
||||
'day': () => t('home.welcomeDay', {username: name.value}),
|
||||
'evening': () => t('home.welcomeEvening', {username: name.value}),
|
||||
} as Record<Daytime, () => string>
|
||||
|
||||
return computed(() => name.value ? salutations[daytime.value]() : undefined)
|
||||
}
|
52
frontend/src/composables/useMenuActive.ts
Normal file
52
frontend/src/composables/useMenuActive.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import {ref, watch, readonly} from 'vue'
|
||||
import {useLocalStorage, useMediaQuery} from '@vueuse/core'
|
||||
import {useRoute} from 'vue-router'
|
||||
|
||||
const BULMA_MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useMenuActive() {
|
||||
const isMobile = useMediaQuery(`(max-width: ${BULMA_MOBILE_BREAKPOINT}px)`)
|
||||
|
||||
const desktopPreference = useLocalStorage(
|
||||
'menuActiveDesktopPreference',
|
||||
true,
|
||||
// If we have two tabs open we want to be able to have the menu open in one window
|
||||
// and closed in the other. The last changed value will be the new preference
|
||||
{listenToStorageChanges: false},
|
||||
)
|
||||
|
||||
const menuActive = ref(false)
|
||||
const route = useRoute()
|
||||
|
||||
// set to prefered value
|
||||
watch(isMobile, (current) => {
|
||||
menuActive.value = current
|
||||
// On mobile we don't show the menu in an expanded state
|
||||
// because that would hide the main content
|
||||
? false
|
||||
: desktopPreference.value
|
||||
}, {immediate: true})
|
||||
|
||||
watch(menuActive, (current) => {
|
||||
if (!isMobile.value) {
|
||||
desktopPreference.value = current
|
||||
}
|
||||
})
|
||||
|
||||
// Hide the menu on mobile when the route changes (e.g. when the user taps a menu item)
|
||||
watch(() => route.fullPath, () => isMobile.value && setMenuActive(false))
|
||||
|
||||
function setMenuActive(newMenuActive: boolean) {
|
||||
menuActive.value = newMenuActive
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
menuActive.value = menuActive.value = !menuActive.value
|
||||
}
|
||||
|
||||
return {
|
||||
menuActive: readonly(menuActive),
|
||||
setMenuActive,
|
||||
toggleMenu,
|
||||
}
|
||||
}
|
13
frontend/src/composables/useOnline.ts
Normal file
13
frontend/src/composables/useOnline.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import {ref} from 'vue'
|
||||
import {useOnline as useNetworkOnline} from '@vueuse/core'
|
||||
import type {ConfigurableWindow} from '@vueuse/core'
|
||||
|
||||
export function useOnline(options?: ConfigurableWindow) {
|
||||
const isOnline = useNetworkOnline(options)
|
||||
const fakeOnlineState = Boolean(import.meta.env.VITE_IS_ONLINE)
|
||||
if (isOnline.value === false && fakeOnlineState) {
|
||||
console.log('Setting fake online state', fakeOnlineState)
|
||||
return ref(true)
|
||||
}
|
||||
return isOnline
|
||||
}
|
35
frontend/src/composables/useRedirectToLastVisited.ts
Normal file
35
frontend/src/composables/useRedirectToLastVisited.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import {useRouter} from 'vue-router'
|
||||
import {getLastVisited, clearLastVisited} from '@/helpers/saveLastVisited'
|
||||
|
||||
export function useRedirectToLastVisited() {
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function getLastVisitedRoute() {
|
||||
const last = getLastVisited()
|
||||
if (last === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
clearLastVisited()
|
||||
return {
|
||||
name: last.name,
|
||||
params: last.params,
|
||||
query: last.query,
|
||||
}
|
||||
}
|
||||
|
||||
function redirectIfSaved() {
|
||||
const lastRoute = getLastVisitedRoute()
|
||||
if (!lastRoute) {
|
||||
return router.push({name: 'home'})
|
||||
}
|
||||
|
||||
return router.push(lastRoute)
|
||||
}
|
||||
|
||||
return {
|
||||
redirectIfSaved,
|
||||
getLastVisitedRoute,
|
||||
}
|
||||
}
|
46
frontend/src/composables/useRenewTokenOnFocus.ts
Normal file
46
frontend/src/composables/useRenewTokenOnFocus.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import {computed} from 'vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
import {useEventListener} from '@vueuse/core'
|
||||
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {MILLISECONDS_A_SECOND, SECONDS_A_HOUR} from '@/constants/date'
|
||||
|
||||
const SECONDS_TOKEN_VALID = 60 * SECONDS_A_HOUR
|
||||
|
||||
export function useRenewTokenOnFocus() {
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const userInfo = computed(() => authStore.info)
|
||||
const authenticated = computed(() => authStore.authenticated)
|
||||
|
||||
// Try renewing the token every time vikunja is loaded initially
|
||||
// (When opening the browser the focus event is not fired)
|
||||
authStore.renewToken()
|
||||
|
||||
// Check if the token is still valid if the window gets focus again to maybe renew it
|
||||
useEventListener('focus', async () => {
|
||||
if (!authenticated.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const nowInSeconds = new Date().getTime() / MILLISECONDS_A_SECOND
|
||||
const expiresIn = userInfo.value !== null
|
||||
? userInfo.value.exp - nowInSeconds
|
||||
: 0
|
||||
|
||||
// If the token expiry is negative, it is already expired and we have no choice but to redirect
|
||||
// the user to the login page
|
||||
if (expiresIn <= 0) {
|
||||
await authStore.checkAuth()
|
||||
await router.push({name: 'user.login'})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the token is valid for less than 60 hours and renew if thats the case
|
||||
if (expiresIn < SECONDS_TOKEN_VALID) {
|
||||
authStore.renewToken()
|
||||
console.debug('renewed token')
|
||||
}
|
||||
})
|
||||
}
|
74
frontend/src/composables/useRouteFilters.ts
Normal file
74
frontend/src/composables/useRouteFilters.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import {computed, ref, watch, type Ref} from 'vue'
|
||||
import {useRouter, type RouteLocationNormalized, type RouteLocationRaw, type RouteRecordName} from 'vue-router'
|
||||
import equal from 'fast-deep-equal/es6'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type Filters = Record<string, any>
|
||||
|
||||
export function useRouteFilters<CurrentFilters extends Filters>(
|
||||
route: Ref<RouteLocationNormalized>,
|
||||
getDefaultFilters: (route: RouteLocationNormalized) => CurrentFilters,
|
||||
routeToFilters: (route: RouteLocationNormalized) => CurrentFilters,
|
||||
filtersToRoute: (filters: CurrentFilters) => RouteLocationRaw,
|
||||
routeAllowList: RouteRecordName[] = [],
|
||||
) {
|
||||
const router = useRouter()
|
||||
|
||||
const filters = ref<CurrentFilters>(routeToFilters(route.value))
|
||||
|
||||
const routeFromFiltersFullPath = computed(() => router.resolve(filtersToRoute(filters.value)).fullPath)
|
||||
|
||||
watch(
|
||||
route.value,
|
||||
(route, oldRoute) => {
|
||||
if (
|
||||
route?.name !== oldRoute?.name ||
|
||||
routeFromFiltersFullPath.value === route.fullPath ||
|
||||
!routeAllowList.includes(route.name ?? '')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
filters.value = routeToFilters(route)
|
||||
},
|
||||
{
|
||||
immediate: true, // set the filter from the initial route
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
filters,
|
||||
async () => {
|
||||
if (routeFromFiltersFullPath.value !== route.value.fullPath) {
|
||||
await router.push(routeFromFiltersFullPath.value)
|
||||
}
|
||||
},
|
||||
// only apply new route after all filters have changed in component cycle
|
||||
{
|
||||
deep: true,
|
||||
flush: 'post',
|
||||
},
|
||||
)
|
||||
|
||||
const hasDefaultFilters = ref(false)
|
||||
watch(
|
||||
[filters, route],
|
||||
([filters, route]) => {
|
||||
hasDefaultFilters.value = equal(filters, getDefaultFilters(route))
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
immediate: true,
|
||||
},
|
||||
)
|
||||
|
||||
function setDefaultFilters() {
|
||||
filters.value = getDefaultFilters(route.value)
|
||||
}
|
||||
|
||||
return {
|
||||
filters,
|
||||
hasDefaultFilters,
|
||||
setDefaultFilters,
|
||||
}
|
||||
}
|
81
frontend/src/composables/useRouteWithModal.ts
Normal file
81
frontend/src/composables/useRouteWithModal.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import {computed, shallowRef, watchEffect, h, type VNode} from 'vue'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
|
||||
export function useRouteWithModal() {
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const backdropView = computed(() => route.fullPath && window.history.state.backdropView)
|
||||
const baseStore = useBaseStore()
|
||||
|
||||
const routeWithModal = computed(() => {
|
||||
return backdropView.value
|
||||
? router.resolve(backdropView.value)
|
||||
: route
|
||||
})
|
||||
|
||||
const currentModal = shallowRef<VNode>()
|
||||
watchEffect(() => {
|
||||
if (!backdropView.value) {
|
||||
currentModal.value = undefined
|
||||
return
|
||||
}
|
||||
|
||||
// this is adapted from vue-router
|
||||
// https://github.com/vuejs/vue-router-next/blob/798cab0d1e21f9b4d45a2bd12b840d2c7415f38a/src/RouterView.ts#L125
|
||||
const routePropsOption = route.matched[0]?.props.default
|
||||
let routeProps = undefined
|
||||
if (routePropsOption) {
|
||||
if (routePropsOption === true) {
|
||||
routeProps = route.params
|
||||
} else {
|
||||
if(typeof routePropsOption === 'function') {
|
||||
routeProps = routePropsOption(route)
|
||||
} else {
|
||||
routeProps = routePropsOption
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof routeProps === 'undefined') {
|
||||
currentModal.value = undefined
|
||||
return
|
||||
}
|
||||
|
||||
routeProps.backdropView = backdropView.value
|
||||
|
||||
const component = route.matched[0]?.components?.default
|
||||
|
||||
if (!component) {
|
||||
currentModal.value = undefined
|
||||
return
|
||||
}
|
||||
currentModal.value = h(component, routeProps)
|
||||
})
|
||||
|
||||
const historyState = computed(() => route.fullPath && window.history.state)
|
||||
|
||||
function closeModal() {
|
||||
|
||||
// If the current project was changed because the user moved the currently opened task while coming from kanban,
|
||||
// we need to reflect that change in the route when they close the task modal.
|
||||
// The last route is only available as resolved string, therefore we need to use a regex for matching here
|
||||
const kanbanRouteMatch = new RegExp('\\/projects\\/\\d+\\/kanban', 'g')
|
||||
const kanbanRouter = {name: 'project.kanban', params: {projectId: baseStore.currentProject?.id}}
|
||||
if (kanbanRouteMatch.test(historyState.value.back)
|
||||
&& baseStore.currentProject
|
||||
&& historyState.value.back !== router.resolve(kanbanRouter).fullPath) {
|
||||
router.push(kanbanRouter)
|
||||
return
|
||||
}
|
||||
|
||||
if (historyState.value) {
|
||||
router.back()
|
||||
} else {
|
||||
const backdropRoute = historyState.value?.backdropView && router.resolve(historyState.value.backdropView)
|
||||
router.push(backdropRoute)
|
||||
}
|
||||
}
|
||||
|
||||
return {routeWithModal, currentModal, closeModal}
|
||||
}
|
149
frontend/src/composables/useTaskList.ts
Normal file
149
frontend/src/composables/useTaskList.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import {ref, shallowReactive, watch, computed, type ComputedGetter} from 'vue'
|
||||
import {useRoute} from 'vue-router'
|
||||
import {useRouteQuery} from '@vueuse/router'
|
||||
|
||||
import TaskCollectionService from '@/services/taskCollection'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import {error} from '@/message'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
export type Order = 'asc' | 'desc' | 'none'
|
||||
|
||||
export interface SortBy {
|
||||
id?: Order
|
||||
index?: Order
|
||||
done?: Order
|
||||
title?: Order
|
||||
priority?: Order
|
||||
due_date?: Order
|
||||
start_date?: Order
|
||||
end_date?: Order
|
||||
percent_done?: Order
|
||||
created?: Order
|
||||
updated?: Order
|
||||
}
|
||||
|
||||
// FIXME: merge with DEFAULT_PARAMS in filters.vue
|
||||
export const getDefaultParams = () => ({
|
||||
sort_by: ['position', 'id'],
|
||||
order_by: ['asc', 'desc'],
|
||||
filter_by: ['done'],
|
||||
filter_value: ['false'],
|
||||
filter_comparator: ['equals'],
|
||||
filter_concat: 'and',
|
||||
})
|
||||
|
||||
const SORT_BY_DEFAULT: SortBy = {
|
||||
id: 'desc',
|
||||
}
|
||||
|
||||
// This makes sure an id sort order is always sorted last.
|
||||
// When tasks would be sorted first by id and then by whatever else was specified, the id sort takes
|
||||
// precedence over everything else, making any other sort columns pretty useless.
|
||||
function formatSortOrder(sortBy, params) {
|
||||
let hasIdFilter = false
|
||||
const sortKeys = Object.keys(sortBy)
|
||||
for (const s of sortKeys) {
|
||||
if (s === 'id') {
|
||||
sortKeys.splice(s, 1)
|
||||
hasIdFilter = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (hasIdFilter) {
|
||||
sortKeys.push('id')
|
||||
}
|
||||
params.sort_by = sortKeys
|
||||
params.order_by = sortKeys.map(s => sortBy[s])
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
/**
|
||||
* This mixin provides a base set of methods and properties to get tasks.
|
||||
*/
|
||||
export function useTaskList(projectIdGetter: ComputedGetter<IProject['id']>, sortByDefault: SortBy = SORT_BY_DEFAULT) {
|
||||
|
||||
const projectId = computed(() => projectIdGetter())
|
||||
|
||||
const params = ref({...getDefaultParams()})
|
||||
|
||||
const search = ref('')
|
||||
const page = useRouteQuery('page', '1', { transform: Number })
|
||||
|
||||
const sortBy = ref({ ...sortByDefault })
|
||||
|
||||
const allParams = computed(() => {
|
||||
const loadParams = {...params.value}
|
||||
|
||||
if (search.value !== '') {
|
||||
loadParams.s = search.value
|
||||
}
|
||||
|
||||
return formatSortOrder(sortBy.value, loadParams)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => allParams.value,
|
||||
() => {
|
||||
// When parameters change, the page should always be the first
|
||||
page.value = 1
|
||||
},
|
||||
)
|
||||
|
||||
const getAllTasksParams = computed(() => {
|
||||
return [
|
||||
{projectId: projectId.value},
|
||||
allParams.value,
|
||||
page.value,
|
||||
]
|
||||
})
|
||||
|
||||
const taskCollectionService = shallowReactive(new TaskCollectionService())
|
||||
const loading = computed(() => taskCollectionService.loading)
|
||||
const totalPages = computed(() => taskCollectionService.totalPages)
|
||||
|
||||
const tasks = ref<ITask[]>([])
|
||||
async function loadTasks() {
|
||||
tasks.value = []
|
||||
try {
|
||||
tasks.value = await taskCollectionService.getAll(...getAllTasksParams.value)
|
||||
} catch (e) {
|
||||
error(e)
|
||||
}
|
||||
return tasks.value
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
watch(() => route.query, (query) => {
|
||||
const { page: pageQueryValue, search: searchQuery } = query
|
||||
if (searchQuery !== undefined) {
|
||||
search.value = searchQuery as string
|
||||
}
|
||||
if (pageQueryValue !== undefined) {
|
||||
page.value = Number(pageQueryValue)
|
||||
}
|
||||
|
||||
}, { immediate: true })
|
||||
|
||||
|
||||
// Only listen for query path changes
|
||||
watch(() => JSON.stringify(getAllTasksParams.value), (newParams, oldParams) => {
|
||||
if (oldParams === newParams) {
|
||||
return
|
||||
}
|
||||
|
||||
loadTasks()
|
||||
}, { immediate: true })
|
||||
|
||||
return {
|
||||
tasks,
|
||||
loading,
|
||||
totalPages,
|
||||
currentPage: page,
|
||||
loadTasks,
|
||||
searchTerm: search,
|
||||
params,
|
||||
sortByParam: sortBy,
|
||||
}
|
||||
}
|
21
frontend/src/composables/useTitle.ts
Normal file
21
frontend/src/composables/useTitle.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import {computed} from 'vue'
|
||||
import type {Ref} from 'vue'
|
||||
|
||||
import {useTitle as useTitleVueUse, toRef} from '@vueuse/core'
|
||||
|
||||
type UseTitleParameters = Parameters<typeof useTitleVueUse>
|
||||
|
||||
export function useTitle(...args: UseTitleParameters) {
|
||||
|
||||
const [newTitle, ...restArgs] = args
|
||||
|
||||
const pageTitle = toRef(newTitle) as Ref<string>
|
||||
|
||||
const completeTitle = computed(() =>
|
||||
(typeof pageTitle.value === 'undefined' || pageTitle.value === '')
|
||||
? 'Vikunja'
|
||||
: `${pageTitle.value} | Vikunja`,
|
||||
)
|
||||
|
||||
return useTitleVueUse(completeTitle, ...restArgs)
|
||||
}
|
Reference in New Issue
Block a user