feature/feat-useList-composable #2589

Closed
dpschen wants to merge 7 commits from dpschen/frontend:feature/feat-useList-composable into main
31 changed files with 634 additions and 531 deletions

View File

@ -1,6 +1,6 @@
<template> <template>
<header <header
:class="{'has-background': background, 'menu-active': menuActive}" :class="{'has-background': hasBackground, 'menu-active': menuActive}"
aria-label="main navigation" aria-label="main navigation"
class="navbar d-print-none" class="navbar d-print-none"
> >
@ -21,7 +21,7 @@
</BaseButton> </BaseButton>
<list-settings-dropdown <list-settings-dropdown
v-if="canWriteCurrentList && currentList.id !== -1" v-if="canWriteCurrentList && currentList.id !== LIST_ID.FAVORITES"
class="list-title-dropdown" class="list-title-dropdown"
:list="currentList" :list="currentList"
> >
@ -88,6 +88,7 @@
import {computed} from 'vue' import {computed} from 'vue'
import {RIGHTS as Rights} from '@/constants/rights' import {RIGHTS as Rights} from '@/constants/rights'
import {LIST_ID} from '@/constants/lists'
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue' import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
import Dropdown from '@/components/misc/dropdown.vue' import Dropdown from '@/components/misc/dropdown.vue'
@ -105,7 +106,7 @@ import {useAuthStore} from '@/stores/auth'
const baseStore = useBaseStore() const baseStore = useBaseStore()
const currentList = computed(() => baseStore.currentList) const currentList = computed(() => baseStore.currentList)
const background = computed(() => baseStore.background) const hasBackground = computed(() => Boolean(baseStore.currentList?.backgroundInformation))
const canWriteCurrentList = computed(() => baseStore.currentList.maxRight > Rights.READ) const canWriteCurrentList = computed(() => baseStore.currentList.maxRight > Rights.READ)
const menuActive = computed(() => baseStore.menuActive) const menuActive = computed(() => baseStore.menuActive)

View File

@ -9,13 +9,14 @@
</BaseButton> </BaseButton>
<div <div
class="app-container" class="app-container"
:class="{'has-background': background || blurHash}" :class="{'has-background': backgroundUrl || blurHashUrl}"
:style="{'background-image': blurHash && `url(${blurHash})`}" :style="{'background-image': blurHashUrl ? `url(${blurHashUrl})` : undefined}"
> >
<div <div
:class="{'is-visible': background}" :class="{'is-visible': backgroundUrl}"
class="app-container-background background-fade-in d-print-none" class="app-container-background background-fade-in d-print-none"
:style="{'background-image': background && `url(${background})`}"></div> :style="{'background-image': backgroundUrl ? `url(${backgroundUrl})` : undefined}">
</div>
<navigation class="d-print-none"/> <navigation class="d-print-none"/>
<main <main
class="app-content" class="app-content"
@ -72,13 +73,15 @@ import {useLabelStore} from '@/stores/labels'
import {useRouteWithModal} from '@/composables/useRouteWithModal' import {useRouteWithModal} from '@/composables/useRouteWithModal'
import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus' import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus'
import {useListBackground} from '@/stores/lists'
const {routeWithModal, currentModal, closeModal} = useRouteWithModal() const {routeWithModal, currentModal, closeModal} = useRouteWithModal()
const baseStore = useBaseStore() const baseStore = useBaseStore()
const background = computed(() => baseStore.background)
const blurHash = computed(() => baseStore.blurHash)
const menuActive = computed(() => baseStore.menuActive) const menuActive = computed(() => baseStore.menuActive)
const currentList = computed(() => baseStore.currentList)
const {backgroundUrl, blurHashUrl} = useListBackground(currentList)
function showKeyboardShortcuts() { function showKeyboardShortcuts() {
baseStore.setKeyboardShortcutsActive(true) baseStore.setKeyboardShortcutsActive(true)

View File

@ -1,7 +1,7 @@
<template> <template>
<div <div
:class="[background ? 'has-background' : '', $route.name as string +'-view']" :class="[backgroundUrl ? 'has-background' : '', $route.name as string +'-view']"
:style="{'background-image': `url(${background})`}" :style="{'background-image': backgroundUrl ? `url(${backgroundUrl})`: undefined}"
class="link-share-container" class="link-share-container"
> >
<div class="container has-text-centered link-share-view"> <div class="container has-text-centered link-share-view">
@ -26,14 +26,18 @@
import {computed} from 'vue' import {computed} from 'vue'
import {useBaseStore} from '@/stores/base' import {useBaseStore} from '@/stores/base'
import {useListBackground} from '@/stores/lists'
import Logo from '@/components/home/Logo.vue' import Logo from '@/components/home/Logo.vue'
import PoweredByLink from './PoweredByLink.vue' import PoweredByLink from './PoweredByLink.vue'
const baseStore = useBaseStore() const baseStore = useBaseStore()
const currentList = computed(() => baseStore.currentList)
const background = computed(() => baseStore.background)
const logoVisible = computed(() => baseStore.logoVisible) const logoVisible = computed(() => baseStore.logoVisible)
const currentList = computed(() => baseStore.currentList)
// TODO: use blurhash here aswell
const{backgroundUrl} = useListBackground(currentList)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -132,8 +132,6 @@ watch(
loadedListId.value = 0 loadedListId.value = 0
const listFromStore = listStore.getListById(listData.id) const listFromStore = listStore.getListById(listData.id)
if (listFromStore !== null) { if (listFromStore !== null) {
baseStore.setBackground(null)
baseStore.setBlurHash(null)
baseStore.handleSetCurrentList({list: listFromStore}) baseStore.handleSetCurrentList({list: listFromStore})
} }

View File

@ -30,6 +30,13 @@
> >
{{ $t('menu.unarchive') }} {{ $t('menu.unarchive') }}
</dropdown-item> </dropdown-item>
<dropdown-item
:to="{ name: 'list.settings.delete', params: { listId: list.id } }"
icon="trash-alt"
class="has-text-danger"
>
{{ $t('menu.delete') }}
</dropdown-item>
</template> </template>
<template v-else> <template v-else>
<dropdown-item <dropdown-item

View File

@ -2,8 +2,8 @@
<div <div
class="list-card" class="list-card"
:class="{ :class="{
'has-light-text': background !== null, 'has-light-text': backgroundUrl !== null,
'has-background': blurHashUrl !== '' || background !== null 'has-background': blurHashUrl !== '' || backgroundUrl !== null
}" }"
:style="{ :style="{
'border-left': list.hexColor ? `0.25rem solid ${list.hexColor}` : undefined, 'border-left': list.hexColor ? `0.25rem solid ${list.hexColor}` : undefined,
@ -12,8 +12,8 @@
> >
<div <div
class="list-background background-fade-in" class="list-background background-fade-in"
:class="{'is-visible': background}" :class="{'is-visible': backgroundUrl}"
:style="{'background-image': background !== null ? `url(${background})` : undefined}" :style="{'background-image': backgroundUrl !== null ? `url(${backgroundUrl})` : undefined}"
/> />
<span v-if="list.isArchived" class="is-archived" >{{ $t('namespace.archived') }}</span> <span v-if="list.isArchived" class="is-archived" >{{ $t('namespace.archived') }}</span>
@ -45,8 +45,7 @@ import type {IList} from '@/modelTypes/IList'
import BaseButton from '@/components/base/BaseButton.vue' import BaseButton from '@/components/base/BaseButton.vue'
import {useListBackground} from './useListBackground' import {useListBackground, useListStore} from '@/stores/lists'
import {useListStore} from '@/stores/lists'
const props = defineProps({ const props = defineProps({
list: { list: {
@ -55,7 +54,7 @@ const props = defineProps({
}, },
}) })
const {background, blurHashUrl} = useListBackground(toRef(props, 'list')) const {backgroundUrl, blurHashUrl} = useListBackground(toRef(props, 'list'))
const listStore = useListStore() const listStore = useListStore()
</script> </script>

View File

@ -1,55 +0,0 @@
import {ref, watch, type Ref} from 'vue'
import ListService from '@/services/list'
import type {IList} from '@/modelTypes/IList'
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
export function useListBackground(list: Ref<IList>) {
const background = ref<string | null>(null)
const backgroundLoading = ref(false)
const blurHashUrl = ref('')
watch(
() => [list.value.id, list.value.backgroundBlurHash] as [IList['id'], IList['backgroundBlurHash']],
async ([listId, blurHash], oldValue) => {
if (
list.value === null ||
!list.value.backgroundInformation ||
backgroundLoading.value
) {
return
}
const [oldListId, oldBlurHash] = oldValue || []
if (
oldValue !== undefined &&
listId === oldListId && blurHash === oldBlurHash
) {
// list hasn't changed
return
}
backgroundLoading.value = true
try {
const blurHashPromise = getBlobFromBlurHash(blurHash).then((blurHash) => {
blurHashUrl.value = blurHash ? window.URL.createObjectURL(blurHash) : ''
})
const listService = new ListService()
const backgroundPromise = listService.background(list.value).then((result) => {
background.value = result
})
await Promise.all([blurHashPromise, backgroundPromise])
} finally {
backgroundLoading.value = false
}
},
{ immediate: true },
)
return {
background,
blurHashUrl,
backgroundLoading,
}
}

View File

@ -69,7 +69,7 @@ import BaseButton from '@/components/base/BaseButton.vue'
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue' import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue'
import {useBaseStore} from '@/stores/base' import {useBaseStore} from '@/stores/base'
import {useListStore} from '@/stores/lists' import {useList, useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces' import {useNamespaceStore} from '@/stores/namespaces'
import {useLabelStore} from '@/stores/labels' import {useLabelStore} from '@/stores/labels'
import {useTaskStore} from '@/stores/tasks' import {useTaskStore} from '@/stores/tasks'
@ -493,18 +493,16 @@ async function newTask() {
await router.push({ name: 'task.detail', params: { id: task.id } }) await router.push({ name: 'task.detail', params: { id: task.id } })
} }
const {createList} = useList()
async function newList() { async function newList() {
if (currentList.value === null) { if (currentList.value === null) {
return return
} }
const newList = await listStore.createList(new ListModel({
await createList({
title: query.value, title: query.value,
namespaceId: currentList.value.namespaceId, namespaceId: currentList.value.namespaceId,
}))
success({ message: t('list.create.createdSuccess')})
await router.push({
name: 'list.index',
params: { listId: newList.id },
}) })
} }

3
src/constants/lists.ts Normal file
View File

@ -0,0 +1,3 @@
export const LIST_ID = {
FAVORITES: -1,
} as const

View File

@ -0,0 +1,5 @@
export const NAMESPACE_ID = {
SHARED_LIST: -1,
FAVORITES: -2,
FILTERS: -3,
} as const

View File

@ -29,3 +29,14 @@ export async function getBlobFromBlurHash(blurHash: string): Promise<Blob | null
}) })
}) })
} }
export async function getBlurHash(blurHashString: string) {
if (!blurHashString) {
return
}
const blurHashBlob = await getBlobFromBlurHash(blurHashString)
if (!blurHashBlob) {
return
}
return URL.createObjectURL(blurHashBlob)
}

View File

@ -1,8 +1,10 @@
import {i18n} from '@/i18n' import {i18n} from '@/i18n'
import type {IList} from '@/modelTypes/IList' import type {IList} from '@/modelTypes/IList'
import {LIST_ID} from '@/constants/lists'
export function getListTitle(l: IList) { export function getListTitle(l: IList) {
if (l.id === -1) { if (l.id === LIST_ID.FAVORITES) {
return i18n.global.t('list.pseudo.favorites.title') return i18n.global.t('list.pseudo.favorites.title')
} }
return l.title return l.title

View File

@ -1,14 +1,16 @@
import {i18n} from '@/i18n' import {i18n} from '@/i18n'
import type {INamespace} from '@/modelTypes/INamespace' import type {INamespace} from '@/modelTypes/INamespace'
import {NAMESPACE_ID} from '@/constants/namespaces'
export const getNamespaceTitle = (n: INamespace) => { export const getNamespaceTitle = (n: INamespace) => {
if (n.id === -1) { if (n.id === NAMESPACE_ID.SHARED_LIST) {
return i18n.global.t('namespace.pseudo.sharedLists.title') return i18n.global.t('namespace.pseudo.sharedLists.title')
} }
if (n.id === -2) { if (n.id === NAMESPACE_ID.FAVORITES) {
return i18n.global.t('namespace.pseudo.favorites.title') return i18n.global.t('namespace.pseudo.favorites.title')
} }
if (n.id === -3) { if (n.id === NAMESPACE_ID.FILTERS) {
return i18n.global.t('namespace.pseudo.savedFilters.title') return i18n.global.t('namespace.pseudo.savedFilters.title')
} }
return n.title return n.title

View File

@ -169,7 +169,6 @@
"title": "List Title", "title": "List Title",
"color": "Color", "color": "Color",
"lists": "Lists", "lists": "Lists",
"list": "List",
konrad marked this conversation as resolved Outdated

this entry is beeing overwritten below

this entry is beeing overwritten below
"search": "Type to search for a list…", "search": "Type to search for a list…",
"searchSelect": "Click or press enter to select this list", "searchSelect": "Click or press enter to select this list",
"shared": "Shared Lists", "shared": "Shared Lists",
@ -187,7 +186,8 @@
"unarchive": "Un-Archive this list", "unarchive": "Un-Archive this list",
"unarchiveText": "You will be able to create new tasks or edit it.", "unarchiveText": "You will be able to create new tasks or edit it.",
"archiveText": "You won't be able to edit this list or create new tasks until you un-archive it.", "archiveText": "You won't be able to edit this list or create new tasks until you un-archive it.",
"success": "The list was successfully archived." "successArchived": "The list has been successfully archived.",
"successUnarchived": "The list has been successfully unarchived."
}, },
"background": { "background": {
"title": "Set list background", "title": "Set list background",

View File

@ -16,10 +16,10 @@ export interface IList extends IAbstract {
hexColor: string hexColor: string
identifier: string identifier: string
backgroundInformation: unknown | null // FIXME: improve type backgroundInformation: unknown | null // FIXME: improve type
backgroundBlurHash: string
isFavorite: boolean isFavorite: boolean
subscription: ISubscription subscription: ISubscription
position: number position: number
backgroundBlurHash: string
created: Date created: Date
updated: Date updated: Date

View File

@ -275,6 +275,7 @@ const router = createRouter({
meta: { meta: {
showAsModal: true, showAsModal: true,
}, },
props: route => ({ namespaceId: Number(route.params.namespaceId as string) }),
}, },
{ {
path: '/lists/:listId/settings/edit', path: '/lists/:listId/settings/edit',
@ -292,6 +293,7 @@ const router = createRouter({
meta: { meta: {
showAsModal: true, showAsModal: true,
}, },
props: route => ({ listId: Number(route.params.listId as string) }),
}, },
{ {
path: '/lists/:listId/settings/duplicate', path: '/lists/:listId/settings/duplicate',
@ -300,6 +302,7 @@ const router = createRouter({
meta: { meta: {
showAsModal: true, showAsModal: true,
}, },
props: route => ({ listId: Number(route.params.listId as string) }),
}, },
{ {
path: '/lists/:listId/settings/share', path: '/lists/:listId/settings/share',
@ -308,6 +311,7 @@ const router = createRouter({
meta: { meta: {
showAsModal: true, showAsModal: true,
}, },
props: route => ({ listId: Number(route.params.listId as string) }),
}, },
{ {
path: '/lists/:listId/settings/delete', path: '/lists/:listId/settings/delete',
@ -316,6 +320,7 @@ const router = createRouter({
meta: { meta: {
showAsModal: true, showAsModal: true,
}, },
props: route => ({ listId: Number(route.params.listId as string) }),
}, },
{ {
path: '/lists/:listId/settings/archive', path: '/lists/:listId/settings/archive',
@ -324,6 +329,7 @@ const router = createRouter({
meta: { meta: {
showAsModal: true, showAsModal: true,
}, },
props: route => ({ listId: Number(route.params.listId as string) }),
}, },
{ {
path: '/lists/:listId/settings/edit', path: '/lists/:listId/settings/edit',

View File

@ -2,7 +2,6 @@ import {AuthenticatedHTTPFactory} from '@/helpers/fetcher'
import type {Method} from 'axios' import type {Method} from 'axios'
import {objectToSnakeCase} from '@/helpers/case' import {objectToSnakeCase} from '@/helpers/case'
import AbstractModel from '@/models/abstractModel'
import type {IAbstract} from '@/modelTypes/IAbstract' import type {IAbstract} from '@/modelTypes/IAbstract'
import type {Right} from '@/constants/rights' import type {Right} from '@/constants/rights'
@ -15,6 +14,11 @@ interface Paths {
reset?: string reset?: string
} }
/**
* The replacement pattern for url paths, can be overwritten by implementations.
*/
const ROUTE_PARAMETER_PATTERN = /{([^}]+)}/
function convertObject(o: Record<string, unknown>) { function convertObject(o: Record<string, unknown>) {
if (o instanceof Date) { if (o instanceof Date) {
return o.toISOString() return o.toISOString()
@ -75,10 +79,8 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
this.http.interceptors.request.use((config) => { this.http.interceptors.request.use((config) => {
switch (config.method) { switch (config.method) {
case 'post': case 'post':
if (this.useUpdateInterceptor()) { config.data = this.beforeUpdate(config.data)
config.data = this.beforeUpdate(config.data) config.data = objectToSnakeCase(config.data)
config.data = objectToSnakeCase(config.data)
}
break break
case 'put': case 'put':
if (this.useCreateInterceptor()) { if (this.useCreateInterceptor()) {
@ -87,10 +89,7 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
} }
break break
case 'delete': case 'delete':
if (this.useDeleteInterceptor()) { config.data = objectToSnakeCase(config.data)
config.data = this.beforeDelete(config.data)
config.data = objectToSnakeCase(config.data)
}
break break
} }
return config return config
@ -106,20 +105,6 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
return true return true
} }
/**
* Whether or not to use the update interceptor which processes a request payload into json
*/
useUpdateInterceptor(): boolean {
return true
}
/**
* Whether or not to use the delete interceptor which processes a request payload into json
*/
useDeleteInterceptor(): boolean {
return true
}
///////////////// /////////////////
// Helper functions // Helper functions
/////////////// ///////////////
@ -127,10 +112,9 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
/** /**
* Returns an object with all route parameters and their values. * Returns an object with all route parameters and their values.
*/ */
getRouteReplacements(route : string, parameters : Record<string, unknown> = {}) { private getRouteReplacements(route: string, parameters: {[key: string]: unknown} = {}) {
const replace$$1: Record<string, unknown> = {} const replace$$1: Record<string, unknown> = {}
let pattern = this.getRouteParameterPattern() const pattern = new RegExp(ROUTE_PARAMETER_PATTERN, 'g')
pattern = new RegExp(pattern instanceof RegExp ? pattern.source : pattern, 'g')
for (let parameter; (parameter = pattern.exec(route)) !== null;) { for (let parameter; (parameter = pattern.exec(route)) !== null;) {
replace$$1[parameter[0]] = parameters[parameter[1]] replace$$1[parameter[0]] = parameters[parameter[1]]
@ -139,17 +123,10 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
return replace$$1 return replace$$1
} }
/**
* Holds the replacement pattern for url paths, can be overwritten by implementations.
*/
getRouteParameterPattern(): RegExp {
return /{([^}]+)}/
}
/** /**
* Returns a fully-ready-ready-to-make-a-request-to route with replaced parameters. * Returns a fully-ready-ready-to-make-a-request-to route with replaced parameters.
*/ */
getReplacedRoute(path : string, pathparams : Record<string, unknown>) : string { getReplacedRoute<M extends {[key: string]: unknown}>(path : string, pathparams : M) : string {
const replacements = this.getRouteReplacements(path, pathparams) const replacements = this.getRouteReplacements(path, pathparams)
return Object.entries(replacements).reduce( return Object.entries(replacements).reduce(
(result, [parameter, value]) => result.replace(parameter, value as string), (result, [parameter, value]) => result.replace(parameter, value as string),
@ -158,8 +135,8 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
} }
/** /**
* setLoading is a method which sets the loading variable to true, after a timeout of 100ms. * Sets the loading variable to true, after a timeout of 100ms.
* It has the timeout to prevent the loading indicator from showing for only a blink of an eye in the * The timeout prevents the loading indicator from showing for only a blink of an eye in the
* case the api returns a response in < 100ms. * case the api returns a response in < 100ms.
* But because the timeout is created using setTimeout, it will still trigger even if the request is * But because the timeout is created using setTimeout, it will still trigger even if the request is
* already finished, so we return a method to call in that case. * already finished, so we return a method to call in that case.
@ -185,36 +162,36 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
* The modelFactory returns a model from an object. * The modelFactory returns a model from an object.
* This one here is the default one, usually the service definitions for a model will override this. * This one here is the default one, usually the service definitions for a model will override this.
*/ */
modelFactory(data : Partial<Model>) { modelFactory<DefaultModel extends {[key: string]: unknown} = Partial<Model>>(data: DefaultModel = {}) {
return data as Model return {...data} as DefaultModel
} }
/** /**
* This is the model factory for get requests. * This is the model factory for get requests.
*/ */
modelGetFactory(data : Partial<Model>) { modelGetFactory<GetModel extends {[key: string]: unknown} = Partial<Model>>(data: GetModel = {}) {
return this.modelFactory(data) return this.modelFactory<GetModel>(data)
} }
/** /**
* This is the model factory for get all requests. * This is the model factory for get all requests.
*/ */
modelGetAllFactory(data : Partial<Model>) { modelGetAllFactory<GetAllModel extends {[key: string]: unknown} = Partial<Model>>(data: GetAllModel = {}) {
return this.modelFactory(data) return this.modelFactory<GetAllModel>(data)
} }
/** /**
* This is the model factory for create requests. * This is the model factory for create requests.
*/ */
modelCreateFactory(data : Partial<Model>) { modelCreateFactory<CreateModel extends {[key: string]: unknown} = Partial<Model>>(data: CreateModel = {}) {
return this.modelFactory(data) return this.modelFactory<CreateModel>(data)
} }
/** /**
* This is the model factory for update requests. * This is the model factory for update requests.
*/ */
modelUpdateFactory(data : Partial<Model>) { modelUpdateFactory<UpdateModel extends {[key: string]: unknown} = Partial<Model>>(data: UpdateModel = {}) {
return this.modelFactory(data) return this.modelFactory<UpdateModel>(data)
} }
////////////// //////////////
@ -224,28 +201,21 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
/** /**
* Default preprocessor for get requests * Default preprocessor for get requests
*/ */
beforeGet(model : Model) { beforeGet(model: Model) {
return model return model
} }
/** /**
* Default preprocessor for create requests * Default preprocessor for create requests
*/ */
beforeCreate(model : Model) { beforeCreate(model: Model) {
return model return model
} }
/** /**
* Default preprocessor for update requests * Default preprocessor for update requests
*/ */
beforeUpdate(model : Model) { beforeUpdate(model: Model) {
return model
}
/**
* Default preprocessor for delete requests
*/
beforeDelete(model : Model) {
return model return model
} }
@ -258,7 +228,7 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
* @param model The model to use. The request path is built using the values from the model. * @param model The model to use. The request path is built using the values from the model.
* @param params Optional query parameters * @param params Optional query parameters
*/ */
get(model : Model, params = {}) { get(model: Model, params = {}) {
if (this.paths.get === '') { if (this.paths.get === '') {
throw new Error('This model is not able to get data.') throw new Error('This model is not able to get data.')
} }
@ -270,14 +240,14 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
* This is a more abstract implementation which only does a get request. * This is a more abstract implementation which only does a get request.
* Services which need more flexibility can use this. * Services which need more flexibility can use this.
*/ */
async getM(url : string, model : Model = new AbstractModel({}), params: Record<string, unknown> = {}) { async getM(url : string, model: Model = this.modelGetFactory(), params: Record<string, unknown> = {}) {
const cancel = this.setLoading() const cancel = this.setLoading()
model = this.beforeGet(model) model = this.beforeGet(model)
const finalUrl = this.getReplacedRoute(url, model) const finalUrl = this.getReplacedRoute(url, model)
try { try {
const response = await this.http.get(finalUrl, {params: prepareParams(params)}) const response = await this.http.get<Model>(finalUrl, {params: prepareParams(params)})
const result = this.modelGetFactory(response.data) const result = this.modelGetFactory(response.data)
result.maxRight = Number(response.headers['x-max-right']) as Right result.maxRight = Number(response.headers['x-max-right']) as Right
return result return result
@ -293,7 +263,7 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
responseType: 'blob', responseType: 'blob',
data, data,
}) })
return window.URL.createObjectURL(new Blob([response.data])) return URL.createObjectURL(new Blob([response.data]))
} }
/** /**
@ -303,7 +273,7 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
* @param params Optional query parameters * @param params Optional query parameters
* @param page The page to get * @param page The page to get
*/ */
async getAll(model : Model = new AbstractModel({}), params = {}, page = 1) { async getAll(model : Model = this.modelFactory(), params = {}, page = 1) {
if (this.paths.getAll === '') { if (this.paths.getAll === '') {
throw new Error('This model is not able to get data.') throw new Error('This model is not able to get data.')
} }
@ -315,7 +285,7 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
const finalUrl = this.getReplacedRoute(this.paths.getAll, model) const finalUrl = this.getReplacedRoute(this.paths.getAll, model)
try { try {
const response = await this.http.get(finalUrl, {params: prepareParams(params)}) const response = await this.http.get<Model>(finalUrl, {params: prepareParams(params)})
this.resultCount = Number(response.headers['x-pagination-result-count']) this.resultCount = Number(response.headers['x-pagination-result-count'])
this.totalPages = Number(response.headers['x-pagination-total-pages']) this.totalPages = Number(response.headers['x-pagination-total-pages'])
@ -326,7 +296,7 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
if (Array.isArray(response.data)) { if (Array.isArray(response.data)) {
return response.data.map(entry => this.modelGetAllFactory(entry)) return response.data.map(entry => this.modelGetAllFactory(entry))
} }
return this.modelGetAllFactory(response.data) return [this.modelGetAllFactory(response.data)]
konrad marked this conversation as resolved Outdated

this seemed wrong. when wouldn't you receive an array here?

this seemed wrong. when wouldn't you receive an array here?

I think there really is no case where no array is returned.

I think there really is no case where no array is returned.

Maybe it would be better to throw instead. Because this fucks up the return types. That's why I added the brackets.

Maybe it would be better to throw instead. Because this fucks up the return types. That's why I added the brackets.

Fixed

Fixed
} finally { } finally {
cancel() cancel()
} }
@ -334,9 +304,8 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
/** /**
* Performs a put request to the url specified before * Performs a put request to the url specified before
* @returns {Promise<any | never>}
*/ */
async create(model : Model) { async create<M = Model>(model: M) {
if (this.paths.create === '') { if (this.paths.create === '') {
throw new Error('This model is not able to create data.') throw new Error('This model is not able to create data.')
} }
@ -345,7 +314,7 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
const finalUrl = this.getReplacedRoute(this.paths.create, model) const finalUrl = this.getReplacedRoute(this.paths.create, model)
try { try {
const response = await this.http.put(finalUrl, model) const response = await this.http.put<M>(finalUrl, model)
const result = this.modelCreateFactory(response.data) const result = this.modelCreateFactory(response.data)
if (typeof model.maxRight !== 'undefined') { if (typeof model.maxRight !== 'undefined') {
result.maxRight = model.maxRight result.maxRight = model.maxRight
@ -360,11 +329,11 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
* An abstract implementation to send post requests. * An abstract implementation to send post requests.
* Services can use this to implement functions to do post requests other than using the update method. * Services can use this to implement functions to do post requests other than using the update method.
*/ */
async post(url : string, model : Model) { async post<PostModel extends {[key: string]: unknown} = Model>(url : string, model: PostModel) {
const cancel = this.setLoading() const cancel = this.setLoading()
try { try {
const response = await this.http.post(url, model) const response = await this.http.post<PostModel>(url, model)
const result = this.modelUpdateFactory(response.data) const result = this.modelUpdateFactory(response.data)
if (typeof model.maxRight !== 'undefined') { if (typeof model.maxRight !== 'undefined') {
result.maxRight = model.maxRight result.maxRight = model.maxRight
@ -378,7 +347,7 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
/** /**
* Performs a post request to the update url * Performs a post request to the update url
*/ */
update(model : Model) { update<M = Model>(model: M) {
if (this.paths.update === '') { if (this.paths.update === '') {
throw new Error('This model is not able to update data.') throw new Error('This model is not able to update data.')
} }

View File

@ -1,7 +1,9 @@
import AbstractService from './abstractService' import type {IBackgroundImage} from '@/modelTypes/IBackgroundImage'
import BackgroundImageModel from '../models/backgroundImage'
import AbstractService from '@/services/abstractService'
import BackgroundImageModel from '@/models/backgroundImage'
import ListModel from '@/models/list' import ListModel from '@/models/list'
import type { IBackgroundImage } from '@/modelTypes/IBackgroundImage'
export default class BackgroundUnsplashService extends AbstractService<IBackgroundImage> { export default class BackgroundUnsplashService extends AbstractService<IBackgroundImage> {
constructor() { constructor() {
@ -11,7 +13,7 @@ export default class BackgroundUnsplashService extends AbstractService<IBackgrou
}) })
} }
modelFactory(data: Partial<IBackgroundImage>) { modelFactory(data) {
return new BackgroundImageModel(data) return new BackgroundImageModel(data)
} }
@ -25,6 +27,6 @@ export default class BackgroundUnsplashService extends AbstractService<IBackgrou
method: 'GET', method: 'GET',
responseType: 'blob', responseType: 'blob',
}) })
return window.URL.createObjectURL(new Blob([response.data])) return URL.createObjectURL(new Blob([response.data]))
} }
} }

View File

@ -1,10 +1,9 @@
import AbstractService from './abstractService' import AbstractService from './abstractService'
import ListModel from '@/models/list' import ListModel from '@/models/list'
import type { IList } from '@/modelTypes/IList' import type {IList} from '@/modelTypes/IList'
import type { IFile } from '@/modelTypes/IFile'
export default class BackgroundUploadService extends AbstractService { export default class BackgroundUploadService extends AbstractService<IList> {
constructor() { constructor() {
super({ super({
create: '/lists/{listId}/backgrounds/upload', create: '/lists/{listId}/backgrounds/upload',
@ -20,9 +19,9 @@ export default class BackgroundUploadService extends AbstractService {
} }
/** /**
* Uploads a file to the server * Uploads a file to the server
*/ */
create(listId: IList['id'], file: IFile) { create(listId: IList['id'], file: File) {
return this.uploadFile( return this.uploadFile(
this.getReplacedRoute(this.paths.create, {listId}), this.getReplacedRoute(this.paths.create, {listId}),
file, file,

View File

@ -1,7 +1,10 @@
import AbstractService from './abstractService'
import ListModel from '@/models/list'
import type {IList} from '@/modelTypes/IList' import type {IList} from '@/modelTypes/IList'
import TaskService from './task'
import AbstractService from '@/services/abstractService'
import TaskService from '@/services/task'
import ListModel from '@/models/list'
import {colorFromHex} from '@/helpers/color/colorFromHex' import {colorFromHex} from '@/helpers/color/colorFromHex'
export default class ListService extends AbstractService<IList> { export default class ListService extends AbstractService<IList> {
@ -34,29 +37,35 @@ export default class ListService extends AbstractService<IList> {
return model return model
} }
beforeCreate(list) { beforeCreate(list: IList) {
list.hexColor = colorFromHex(list.hexColor) list.hexColor = colorFromHex(list.hexColor)
return list return list
} }
async background(list: Pick<IList, 'id' | 'backgroundInformation'>) { async loadBackground(list: Pick<IList, 'id' | 'backgroundInformation'>) {
if (list.backgroundInformation === null) { if (list === null || !list.backgroundInformation) {
return '' return
}
const cancel = this.setLoading()
dpschen marked this conversation as resolved Outdated

unsure: maybe I should remove the setLoading here since the list shouldn't indicate a loading just for the background?

unsure: maybe I should remove the setLoading here since the list shouldn't indicate a loading just for the background?

I think that would make sense.

I think that would make sense.

Fixed

Fixed
try {
const response = await this.http({
url: `/lists/${list.id}/background`,
method: 'GET',
responseType: 'blob',
})
return URL.createObjectURL(new Blob([response.data]))
} finally {
cancel()
} }
const response = await this.http({
url: `/lists/${list.id}/background`,
method: 'GET',
responseType: 'blob',
})
return window.URL.createObjectURL(new Blob([response.data]))
} }
async removeBackground(list: Pick<IList, 'id'>) { async removeBackground(list: Pick<IList, 'id'>) {
const cancel = this.setLoading() const cancel = this.setLoading()
try { try {
const response = await this.http.delete(`/lists/${list.id}/background`, list) const response = await this.http.delete<IList>(`/lists/${list.id}/background`)
return response.data return response.data
} finally { } finally {
cancel() cancel()

View File

@ -100,7 +100,7 @@ export default class TaskService extends AbstractService<ITask> {
}) })
}) })
// Process all attachments to preven parsing errors // Process all attachments to prevent parsing errors
if (model.attachments.length > 0) { if (model.attachments.length > 0) {
const attachmentService = new AttachmentService() const attachmentService = new AttachmentService()
model.attachments.map(a => { model.attachments.map(a => {

View File

@ -1,10 +1,7 @@
import { readonly, ref} from 'vue' import { readonly, ref} from 'vue'
import {defineStore, acceptHMRUpdate} from 'pinia' import {defineStore, acceptHMRUpdate} from 'pinia'
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
import ListModel from '@/models/list' import ListModel from '@/models/list'
import ListService from '../services/list'
import {checkAndSetApiUrl} from '@/helpers/checkAndSetApiUrl' import {checkAndSetApiUrl} from '@/helpers/checkAndSetApiUrl'
import {useMenuActive} from '@/composables/useMenuActive' import {useMenuActive} from '@/composables/useMenuActive'
@ -21,8 +18,6 @@ export const useBaseStore = defineStore('base', () => {
id: 0, id: 0,
isArchived: false, isArchived: false,
})) }))
const background = ref('')
const blurHash = ref('')
const hasTasks = ref(false) const hasTasks = ref(false)
const keyboardShortcutsActive = ref(false) const keyboardShortcutsActive = ref(false)
@ -62,14 +57,6 @@ export const useBaseStore = defineStore('base', () => {
quickActionsActive.value = value quickActionsActive.value = value
} }
function setBackground(newBackground: string) {
background.value = newBackground
}
function setBlurHash(newBlurHash: string) {
blurHash.value = newBlurHash
}
function setLogoVisible(visible: boolean) { function setLogoVisible(visible: boolean) {
logoVisible.value = visible logoVisible.value = visible
} }
@ -78,43 +65,14 @@ export const useBaseStore = defineStore('base', () => {
ready.value = value ready.value = value
} }
async function handleSetCurrentList( function handleSetCurrentList(
{list, forceUpdate = false}: {list: IList | null, forceUpdate?: boolean}, {list}: {list: IList | null},
) { ) {
if (list === null) { if (list === null) {
setCurrentList({}) setCurrentList({})
setBackground('')
setBlurHash('')
return return
} }
// The forceUpdate parameter is used only when updating a list background directly because in that case
// the current list stays the same, but we want to show the new background right away.
if (list.id !== currentList.value?.id || forceUpdate) {
if (list.backgroundInformation) {
try {
const blurHash = await getBlobFromBlurHash(list.backgroundBlurHash)
if (blurHash) {
setBlurHash(window.URL.createObjectURL(blurHash))
}
const listService = new ListService()
const background = await listService.background(list)
setBackground(background)
} catch (e) {
console.error('Error getting background image for list', list.id, e)
}
}
}
if (
typeof list.backgroundInformation === 'undefined' ||
list.backgroundInformation === null
) {
setBackground('')
setBlurHash('')
}
setCurrentList(list) setCurrentList(list)
} }
@ -129,8 +87,6 @@ export const useBaseStore = defineStore('base', () => {
loading: readonly(loading), loading: readonly(loading),
ready: readonly(ready), ready: readonly(ready),
currentList: readonly(currentList), currentList: readonly(currentList),
background: readonly(background),
blurHash: readonly(blurHash),
hasTasks: readonly(hasTasks), hasTasks: readonly(hasTasks),
keyboardShortcutsActive: readonly(keyboardShortcutsActive), keyboardShortcutsActive: readonly(keyboardShortcutsActive),
quickActionsActive: readonly(quickActionsActive), quickActionsActive: readonly(quickActionsActive),
@ -142,8 +98,6 @@ export const useBaseStore = defineStore('base', () => {
setHasTasks, setHasTasks,
setKeyboardShortcutsActive, setKeyboardShortcutsActive,
setQuickActionsActive, setQuickActionsActive,
setBackground,
setBlurHash,
setLogoVisible, setLogoVisible,
handleSetCurrentList, handleSetCurrentList,

View File

@ -1,25 +1,38 @@
import {watch, reactive, shallowReactive, unref, toRefs, readonly, ref, computed} from 'vue' import {watch, shallowReactive, unref, toRefs, readonly, ref, computed} from 'vue'
import type {MaybeRef} from '@vueuse/core'
import {acceptHMRUpdate, defineStore} from 'pinia' import {acceptHMRUpdate, defineStore} from 'pinia'
import {useI18n} from 'vue-i18n' import {useI18n} from 'vue-i18n'
import {useRouter} from 'vue-router'
import ListService from '@/services/list' import router from '@/router'
import {setModuleLoading} from '@/stores/helper'
import {removeListFromHistory} from '@/modules/listHistory'
import {createNewIndexer} from '@/indexes'
import {useNamespaceStore} from './namespaces'
import type {IList} from '@/modelTypes/IList' import type {IList} from '@/modelTypes/IList'
import type {INamespace} from '@/modelTypes/INamespace'
import type {IBackgroundImage} from '@/modelTypes/IBackgroundImage'
import type {MaybeRef} from '@vueuse/core' import {LIST_ID} from '@/constants/lists'
import {NAMESPACE_ID} from '@/constants/namespaces'
import ListService from '@/services/list'
import ListDuplicateService from '@/services/listDuplicateService'
import BackgroundUnsplashService from '@/services/backgroundUnsplash'
import ListModel from '@/models/list' import ListModel from '@/models/list'
import ListDuplicateModel from '@/models/listDuplicateModel'
import {removeListFromHistory} from '@/modules/listHistory'
import {setModuleLoading} from '@/stores/helper'
import {useBaseStore} from './base'
import {useNamespaceStore} from './namespaces'
import {createNewIndexer} from '@/indexes'
import {success} from '@/message' import {success} from '@/message'
import {useBaseStore} from '@/stores/base' import {i18n} from '@/i18n'
import BackgroundUploadService from '@/services/backgroundUpload'
import {getBlurHash} from '@/helpers/blurhash'
import cloneDeep from 'lodash.clonedeep'
const {add, remove, search, update} = createNewIndexer('lists', ['title', 'description']) const {add, remove, search, update} = createNewIndexer('lists', ['title', 'description'])
const FavoriteListsNamespace = -2
export interface ListState { export interface ListState {
[id: IList['id']]: IList [id: IList['id']]: IList
} }
@ -35,7 +48,9 @@ export const useListStore = defineStore('list', () => {
const getListById = computed(() => { const getListById = computed(() => {
return (id: IList['id']) => typeof lists.value[id] !== 'undefined' ? lists.value[id] : null return (id: IList['id']) => typeof lists.value[id] !== 'undefined'
? lists.value[id]
: null
}) })
const findListByExactname = computed(() => { const findListByExactname = computed(() => {
@ -53,7 +68,7 @@ export const useListStore = defineStore('list', () => {
?.filter(value => value > 0) ?.filter(value => value > 0)
.map(id => lists.value[id]) .map(id => lists.value[id])
.filter(list => list.isArchived === includeArchived) .filter(list => list.isArchived === includeArchived)
|| [] || [] as IList[]
} }
}) })
@ -77,15 +92,10 @@ export const useListStore = defineStore('list', () => {
}) })
} }
function removeListById(list: IList) {
remove(list)
delete lists.value[list.id]
}
function toggleListFavorite(list: IList) { function toggleListFavorite(list: IList) {
// The favorites pseudo list is always favorite // The favorites pseudo list is always favorite
// Archived lists cannot be marked favorite // Archived lists cannot be marked favorite
if (list.id === -1 || list.isArchived) { if (list.id === LIST_ID.FAVORITES || list.isArchived) {
return return
} }
return updateList({ return updateList({
@ -94,6 +104,20 @@ export const useListStore = defineStore('list', () => {
}) })
} }
async function loadList(listId: List['id']) {
dpschen marked this conversation as resolved Outdated

remove 'this'

remove 'this'

Was already fixed

Was already fixed
const cancel = setModuleLoading(this, setIsLoading)
const listService = new ListService()
try {
const list = await listService.get(new ListModel({id: listId}))
setList(list)
namespaceStore.setListInNamespaceById(list)
return list
} finally {
cancel()
}
}
async function createList(list: IList) { async function createList(list: IList) {
const cancel = setModuleLoading(setIsLoading) const cancel = setModuleLoading(setIsLoading)
const listService = new ListService() const listService = new ListService()
@ -113,6 +137,8 @@ export const useListStore = defineStore('list', () => {
const cancel = setModuleLoading(setIsLoading) const cancel = setModuleLoading(setIsLoading)
const listService = new ListService() const listService = new ListService()
const oldList = cloneDeep(getListById.value(list.id) as IList)
try { try {
await listService.update(list) await listService.update(list)
setList(list) setList(list)
@ -120,24 +146,18 @@ export const useListStore = defineStore('list', () => {
// the returned list from listService.update is the same! // the returned list from listService.update is the same!
// in order to not create a manipulation in pinia store we have to create a new copy // in order to not create a manipulation in pinia store we have to create a new copy
const newList = { const newList = {...list}
...list,
namespaceId: FavoriteListsNamespace,
}
namespaceStore.removeListFromNamespaceById(newList)
if (list.isFavorite) { if (list.isFavorite) {
namespaceStore.addListToNamespace(newList) namespaceStore.addListToNamespace(newList, NAMESPACE_ID.FAVORITES)
} else {
namespaceStore.removeListFromNamespaceById(newList, NAMESPACE_ID.FAVORITES)
} }
namespaceStore.loadNamespacesIfFavoritesDontExist() namespaceStore.loadNamespacesIfFavoritesDontExist()
namespaceStore.removeFavoritesNamespaceIfEmpty() namespaceStore.removeFavoritesNamespaceIfEmpty()
return newList return newList
} catch (e) { } catch (e) {
// Reset the list state to the initial one to avoid confusion for the user // Reset the list state to the initial one to avoid confusion for the user
setList({ setList(oldList)
...list,
isFavorite: !list.isFavorite,
})
throw e throw e
} finally { } finally {
cancel() cancel()
@ -150,7 +170,8 @@ export const useListStore = defineStore('list', () => {
try { try {
const response = await listService.delete(list) const response = await listService.delete(list)
removeListById(list) remove(list)
delete lists.value[list.id]
namespaceStore.removeListFromNamespaceById(list) namespaceStore.removeListFromNamespaceById(list)
removeListFromHistory({id: list.id}) removeListFromHistory({id: list.id})
return response return response
@ -159,6 +180,124 @@ export const useListStore = defineStore('list', () => {
} }
} }
async function toggleArchiveList(list: IList) {
try {
const newList = await updateList({
...list,
isArchived: !list.isArchived,
})
baseStore.setCurrentList(newList)
success({message: newList.isArchived
? i18n.global.t('list.archive.successArchived')
: i18n.global.t('list.archive.successUnarchived'),
})
} finally {
router.back()
}
}
const listDuplicateService = shallowReactive(new ListDuplicateService())
const isDuplicatingList = computed(() => listDuplicateService.loading)
async function duplicateList(listId: IList['id'], namespaceId: INamespace['id'] | undefined) {
if (namespaceId === undefined) {
throw new Error()
}
const listDuplicate = new ListDuplicateModel({
listId,
namespaceId: namespaceId,
})
const duplicate = await listDuplicateService.create(listDuplicate)
namespaceStore.addListToNamespace(duplicate.list)
setList(duplicate.list)
success({message: i18n.global.t('list.duplicate.success')})
router.push({name: 'list.index', params: {listId: duplicate.list.id}})
}
dpschen marked this conversation as resolved Outdated

in case of the modal, where the modal has a view we should replace the view instead. maybe always?

in case of the modal, where the modal has a view we should replace the view instead. maybe always?

Doesn't the view in list.index do that?

Doesn't the view in `list.index` do that?

Yes it does. But we still use router.push(). What I meant was using router.replace() instead, because you wouldn't want to go back to the route of the dialog.

Yes it does. But we still use `router.push()`. What I meant was using `router.replace()` instead, because you wouldn't want to go back to the route of the dialog.

For routes like the task detail popup we should have the option to go back to the route with a popup. Maybe for settings as well.

For routes like the task detail popup we should have the option to go back to the route with a popup. Maybe for settings as well.

Yes, maybe this should be configurable via a prop

Yes, maybe this should be configurable via a prop

Keeping this as it is for now. This is something that should be tackled in the modal or even in the dialog branch later.

Keeping this as it is for now. This is something that should be tackled in the modal or even in the dialog branch later.
const backgroundService = shallowReactive(new BackgroundUnsplashService())
async function setUnsplashBackground(listId: IList['id'], backgroundId: IBackgroundImage['id']) {
// Don't set a background if we're in the process of setting one
if (backgroundService.loading || listId === undefined) {
return
}

why not?

why not?

I think this was to prevent multiple clicks quickly after each other. The better solution would of course be disabling the button.

I think this was to prevent multiple clicks quickly after each other. The better solution would of course be disabling the button.

Okay, will do that instead.

Okay, will do that instead.
const list: IList = await backgroundService.update({
id: backgroundId,
listId,
})
dpschen marked this conversation as resolved Outdated

fix: type should be returned by update.

fix: type should be returned by update.

Fixed

Fixed
baseStore.handleSetCurrentList({list})
namespaceStore.setListInNamespaceById(list)
setList(list)
success({message: i18n.global.t('list.background.success')})
}
const backgroundUploadService = shallowReactive(new BackgroundUploadService())
const isUploadingBackground = computed(() => backgroundUploadService.loading)
async function uploadListBackground(listId: IList['id'], file: File | undefined) {
if (listId === undefined || file === undefined) {
return
}
const list = await backgroundUploadService.create(listId, file)
baseStore.handleSetCurrentList({list})
namespaceStore.setListInNamespaceById(list)
setList(list)
success({message: i18n.global.t('list.background.success')})
}
async function removeListBackground(listId: IList['id']) {
const listService = new ListService()
const listWithBackground = getListById.value(listId)
if (listWithBackground === null) {
return
}
const list = await listService.removeBackground(listWithBackground)
baseStore.handleSetCurrentList({list})
namespaceStore.setListInNamespaceById(list)
setList(list)
success({message: i18n.global.t('list.background.removeSuccess')})
router.back()
}
async function loadListBackground(
list: IList,
blurhashSetter: (blurhash: string) => void,
backgroundSetter: (background: string) => void,
) {
if (
list === null ||
!list.backgroundInformation
) {
blurhashSetter('')
backgroundSetter('')
return
}
try {
const listService = new ListService()
const blurHashPromise = getBlurHash(list.backgroundBlurHash).then(blurHash => {
blurhashSetter(blurHash || '')
})
const backgroundPromise = listService.loadBackground(list).then(background => {
if (background === undefined) {
throw new Error()
}
backgroundSetter(background)
})
await Promise.all([
blurHashPromise,
backgroundPromise,
])
} catch (e) {
console.error('Error getting background image for list', list.id, e)
}
}
return { return {
isLoading: readonly(isLoading), isLoading: readonly(isLoading),
lists: readonly(lists), lists: readonly(lists),
@ -169,43 +308,185 @@ export const useListStore = defineStore('list', () => {
setList, setList,
setLists, setLists,
removeListById,
toggleListFavorite, toggleListFavorite,
// crud
loadList,
createList, createList,
updateList, updateList,
deleteList, deleteList,
toggleArchiveList,
//duplciate
isDuplicatingList,
duplicateList,
isUploadingBackground,
uploadListBackground,
removeListBackground,
setUnsplashBackground,
loadListBackground,
} }
}) })
export function useList(listId: MaybeRef<IList['id']>) {
const listService = shallowReactive(new ListService())
const {loading: isLoading} = toRefs(listService)
const list: IList = reactive(new ListModel())
const {t} = useI18n({useScope: 'global'})
watch(
() => unref(listId),
async (listId) => {
const loadedList = await listService.get(new ListModel({id: listId}))
Object.assign(list, loadedList)
},
{immediate: true},
)
const listStore = useListStore()
async function save() {
await listStore.updateList(list)
success({message: t('list.edit.success')})
}
return {
isLoading: readonly(isLoading),
list,
save,
}
}
// support hot reloading // support hot reloading
if (import.meta.hot) { if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useListStore, import.meta.hot)) import.meta.hot.accept(acceptHMRUpdate(useListStore, import.meta.hot))
}
export function useListBase(listId?: MaybeRef<IList['id']>) {
const listStore = useListStore()
const listService = shallowReactive(new ListService())
const {loading: isLoading} = toRefs(listService)
const list = ref<IList>(new ListModel())
const currentListId = computed(() => unref(listId))
// load list
watch(
currentListId,
async (listId, oldListId) => {
if (listId === oldListId) {
return
}
if (listId === undefined) {
list.value = new ListModel()
return
}
list.value = listStore.getListById(listId) || list.value
// FIXME: does this also make sense for a newly created list?
list.value = await listService.get(new ListModel({id: listId}))
},
{immediate: true},
)
return {
listStore,
listService,
isLoading,
currentListId,
list,
}
}
export function useList(listId?: MaybeRef<IList['id']>) {
const {
listStore,
listService,
isLoading,
list,
} = useListBase(listId)
const {t} = useI18n({useScope: 'global'})
const router = useRouter()
const baseStore = useBaseStore()
const namespaceStore = useNamespaceStore()
const errors = ref<{ [key: string]: string | boolean }>({})
async function createList(newList?: Partial<IList>) {
if (newList !== undefined) {
list.value = new ListModel(newList)
}
if (list.value.title === '') {
errors.value.createList = true
return
}
errors.value.createList = false
list.value = await listStore.createList(list.value)
await router.push({
name: 'list.index',
params: { listId: list.value.id },
})
success({message: t('list.create.createdSuccess') })
}
async function saveList() {
await listStore.updateList(list.value)
success({message: t('list.edit.success')})
baseStore.handleSetCurrentList({list: list.value})
router.back()
}
async function deleteList() {
if (!list.value) {
return
}
await listStore.deleteList(list.value)
success({message: t('list.delete.success')})
router.push({name: 'home'})
}
async function removeListBackground() {
list.value = await listService.removeBackground(list.value)
useBaseStore().handleSetCurrentList({list: list.value})
namespaceStore.setListInNamespaceById(list.value)
listStore.setList(list.value)
success({message: t('list.background.removeSuccess')})
router.back()
}
return {
isLoading: readonly(isLoading),
errors: readonly(errors),
list,
createList,
saveList,
deleteList,
removeListBackground,
}
}
export function useListBackground(list: MaybeRef<IList>) {
const listStore = useListStore()
const listVal = computed(() => unref(list))
const backgroundUrl = ref<string | null>(null)
const backgroundLoading = ref(false)
const blurHashUrl = ref('')
watch(
() => [listVal.value.id, listVal.value.backgroundBlurHash] as [IList['id'], IList['backgroundBlurHash']],
async ([listId, blurHash], oldValue) => {
if (backgroundLoading.value) {
return
}

I copied this from the old code, but I think that we shouldn't return in that case

I copied this from the old code, but I think that we shouldn't return in that case
const [oldListId, oldBlurHash] = oldValue || []
if (
oldValue !== undefined &&
listId === oldListId && blurHash === oldBlurHash
) {
// list hasn't changed
return
}
backgroundLoading.value = true
await listStore.loadListBackground(
listVal.value,
(value) => {
blurHashUrl.value = value
},
(value) => {
backgroundUrl.value = value

if the list or background has changed in between we shouldn't set this.

if the list or background has changed in between we shouldn't set this.

When could that be the case?

When could that be the case?

If you click on one list that has a background and then on another that has none. The promise would still finish and then set the (wrong) background.

If you click on one list that has a background and then on another that has none. The promise would still finish and then set the (wrong) background.

Makes sense!

Makes sense!
},
)
backgroundLoading.value = false
},
{ immediate: true },
)
return {
backgroundUrl,
blurHashUrl,
backgroundLoading,
}
} }

View File

@ -1,13 +1,17 @@
import {computed, readonly, ref} from 'vue' import {computed, readonly, ref} from 'vue'
import {defineStore, acceptHMRUpdate} from 'pinia' import {defineStore, acceptHMRUpdate} from 'pinia'
import NamespaceService from '../services/namespace'
import {setModuleLoading} from '@/stores/helper'
import {createNewIndexer} from '@/indexes'
import type {INamespace} from '@/modelTypes/INamespace' import type {INamespace} from '@/modelTypes/INamespace'
import type {IList} from '@/modelTypes/IList' import type {IList} from '@/modelTypes/IList'
import {NAMESPACE_ID} from '@/constants/namespaces'
import {setModuleLoading} from '@/stores/helper'
import {useListStore} from '@/stores/lists' import {useListStore} from '@/stores/lists'
import {createNewIndexer} from '@/indexes'
import NamespaceService from '@/services/namespace'
const {add, remove, search, update} = createNewIndexer('namespaces', ['title', 'description']) const {add, remove, search, update} = createNewIndexer('namespaces', ['title', 'description'])
export const useNamespaceStore = defineStore('namespace', () => { export const useNamespaceStore = defineStore('namespace', () => {
@ -123,20 +127,20 @@ export const useNamespaceStore = defineStore('namespace', () => {
} }
} }
function addListToNamespace(list: IList) { function addListToNamespace(list: IList, namespaceId?: INamespace['id']) {
for (const n in namespaces.value) { for (const n in namespaces.value) {
if (namespaces.value[n].id === list.namespaceId) { if (namespaces.value[n].id === (namespaceId ?? list.namespaceId)) {
namespaces.value[n].lists.push(list) namespaces.value[n].lists.push(list)
return return
} }
} }
} }
function removeListFromNamespaceById(list: IList) { function removeListFromNamespaceById(list: IList, namespaceId?: INamespace['id']) {
for (const n in namespaces.value) { for (const n in namespaces.value) {
// We don't have the namespace id on the list which means we need to loop over all lists until we find it. // We don't have the namespace id on the list which means we need to loop over all lists until we find it.
// FIXME: Not ideal at all - we should fix that at the api level. // FIXME: Not ideal at all - we should fix that at the api level.
if (namespaces.value[n].id === list.namespaceId) { if (namespaces.value[n].id === (namespaceId ?? list.namespaceId)) {
for (const l in namespaces.value[n].lists) { for (const l in namespaces.value[n].lists) {
if (namespaces.value[n].lists[l].id === list.id) { if (namespaces.value[n].lists[l].id === list.id) {
namespaces.value[n].lists.splice(l, 1) namespaces.value[n].lists.splice(l, 1)
@ -169,14 +173,20 @@ export const useNamespaceStore = defineStore('namespace', () => {
function loadNamespacesIfFavoritesDontExist() { function loadNamespacesIfFavoritesDontExist() {
// The first or second namespace should be the one holding all favorites // The first or second namespace should be the one holding all favorites
if (namespaces.value[0].id === -2 || namespaces.value[1]?.id === -2) { if (
namespaces.value[0].id === NAMESPACE_ID.FAVORITES ||
namespaces.value[1]?.id === NAMESPACE_ID.FAVORITES
) {
return return
} }
return loadNamespaces() return loadNamespaces()
} }
function removeFavoritesNamespaceIfEmpty() { function removeFavoritesNamespaceIfEmpty() {
if (namespaces.value[0].id === -2 && namespaces.value[0].lists.length === 0) { if (
namespaces.value[0].id === NAMESPACE_ID.FAVORITES &&
namespaces.value[0].lists.length === 0
) {
namespaces.value.splice(0, 1) namespaces.value.splice(0, 1)
} }
} }

View File

@ -1,13 +1,17 @@
<template> <template>
<create-edit :title="$t('list.create.header')" @create="createNewList()" :primary-disabled="list.title === ''"> <create-edit
:title="$t('list.create.header')"
@create="createNewList()"
:primary-disabled="list.title === ''"
>
<div class="field"> <div class="field">
<label class="label" for="listTitle">{{ $t('list.title') }}</label> <label class="label" for="listTitle">{{ $t('list.title') }}</label>
<div <div
:class="{ 'is-loading': listService.loading }" :class="{ 'is-loading': isLoading }"
class="control" class="control"
> >
<input <input
:class="{ disabled: listService.loading }" :class="{ disabled: isLoading }"
@keyup.enter="createNewList()" @keyup.enter="createNewList()"
@keyup.esc="$router.back()" @keyup.esc="$router.back()"
class="input" class="input"
@ -19,7 +23,7 @@
/> />
</div> </div>
</div> </div>
<p class="help is-danger" v-if="showError && list.title === ''"> <p class="help is-danger" v-if="errors.createList && list.title === ''">
{{ $t('list.create.addTitleRequired') }} {{ $t('list.create.addTitleRequired') }}
</p> </p>
<div class="field"> <div class="field">
@ -32,43 +36,29 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {ref, reactive, shallowReactive} from 'vue' import {watchEffect} from 'vue'
import {useI18n} from 'vue-i18n' import {useI18n} from 'vue-i18n'
import {useRouter, useRoute} from 'vue-router'
import ListService from '@/services/list' import type {INamespace} from '@/modelTypes/INamespace'
import ListModel from '@/models/list'
import CreateEdit from '@/components/misc/create-edit.vue' import CreateEdit from '@/components/misc/create-edit.vue'
import ColorPicker from '@/components/input/ColorPicker.vue' import ColorPicker from '@/components/input/ColorPicker.vue'
import {success} from '@/message'
import {useTitle} from '@/composables/useTitle' import {useTitle} from '@/composables/useTitle'
import {useListStore} from '@/stores/lists' import {useList} from '@/stores/lists'
const {namespaceId} = defineProps<{
namespaceId: INamespace['id']
}>()
const {t} = useI18n({useScope: 'global'}) const {t} = useI18n({useScope: 'global'})
const router = useRouter()
const route = useRoute()
useTitle(() => t('list.create.header')) useTitle(() => t('list.create.header'))
const showError = ref(false) const {isLoading, errors, list, createList} = useList()
const list = reactive(new ListModel())
const listService = shallowReactive(new ListService())
const listStore = useListStore()
async function createNewList() { watchEffect(() => {
if (list.title === '') { list.value.namespaceId = namespaceId
showError.value = true })
return
}
showError.value = false
list.namespaceId = Number(route.params.namespaceId as string) const createNewList = () => createList()
const newList = await listStore.createList(list)
await router.push({
name: 'list.index',
params: { listId: newList.id },
})
success({message: t('list.create.createdSuccess') })
}
</script> </script>

View File

@ -1,12 +1,22 @@
<template> <template>
<modal <modal
@close="$router.back()" @close="$router.back()"
@submit="archiveList()" @submit="listStore.toggleArchiveList(list)"
> >
<template #header><span>{{ list.isArchived ? $t('list.archive.unarchive') : $t('list.archive.archive') }}</span></template> <template #header>
<span>{{
list.isArchived
? $t('list.archive.unarchive')
: $t('list.archive.archive')
}}</span>
</template>
<template #text> <template #text>
<p>{{ list.isArchived ? $t('list.archive.unarchiveText') : $t('list.archive.archiveText') }}</p> <p>{{
list.isArchived
? $t('list.archive.unarchiveText')
: $t('list.archive.archiveText')
}}</p>
</template> </template>
</modal> </modal>
</template> </template>
@ -16,34 +26,21 @@ export default {name: 'list-setting-archive'}
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
import {computed} from 'vue'
import {useRouter, useRoute} from 'vue-router'
import {useI18n} from 'vue-i18n' import {useI18n} from 'vue-i18n'
import {success} from '@/message'
import {useTitle} from '@/composables/useTitle' import {useTitle} from '@/composables/useTitle'
import {useBaseStore} from '@/stores/base' import {useList, useListStore} from '@/stores/lists'
import {useListStore} from '@/stores/lists' import type {IList} from '@/modelTypes/IList'
const {listId} = defineProps<{
listId: IList['id']
}>()
const {t} = useI18n({useScope: 'global'}) const {t} = useI18n({useScope: 'global'})
const listStore = useListStore() const listStore = useListStore()
const router = useRouter() const {list} = useList(listId)
const route = useRoute()
const list = computed(() => listStore.getListById(route.params.listId))
useTitle(() => t('list.archive.title', {list: list.value.title})) useTitle(() => t('list.archive.title', {list: list.value.title}))
async function archiveList() {
try {
const newList = await listStore.updateList({
...list.value,
isArchived: !list.value.isArchived,
})
useBaseStore().setCurrentList(newList)
success({message: t('list.archive.success')})
} finally {
router.back()
}
}
</script> </script>

View File

@ -8,14 +8,14 @@
> >
<div class="mb-4" v-if="uploadBackgroundEnabled"> <div class="mb-4" v-if="uploadBackgroundEnabled">
<input <input
@change="uploadBackground" @change="listStore.uploadListBackground(listId, ($event.target as HTMLInputElement)?.files?.[0])"
accept="image/*" accept="image/*"
class="is-hidden" class="is-hidden"
ref="backgroundUploadInput" ref="backgroundUploadInput"
type="file" type="file"
/> />
<x-button <x-button
:loading="backgroundUploadService.loading" :loading="listStore.isUploadingBackground"
@click="backgroundUploadInput?.click()" @click="backgroundUploadInput?.click()"
variant="primary" variant="primary"
> >
@ -47,7 +47,7 @@
<BaseButton <BaseButton
v-if="backgroundThumbs[im.id]" v-if="backgroundThumbs[im.id]"
class="image-search__image-button" class="image-search__image-button"
@click="setBackground(im.id)" @click="listStore.setUnsplashBackground(listId, im.id)"
> >
<img class="image-search__image" :src="backgroundThumbs[im.id]" alt="" /> <img class="image-search__image" :src="backgroundThumbs[im.id]" alt="" />
</BaseButton> </BaseButton>
@ -79,7 +79,7 @@
:shadow="false" :shadow="false"
variant="tertiary" variant="tertiary"
class="is-danger" class="is-danger"
@click.prevent.stop="removeBackground" @click.prevent.stop="listStore.removeListBackground(listId)"
> >
{{ $t('list.background.remove') }} {{ $t('list.background.remove') }}
</x-button> </x-button>
@ -100,42 +100,40 @@ export default { name: 'list-setting-background' }
<script setup lang="ts"> <script setup lang="ts">
import {ref, computed, shallowReactive} from 'vue' import {ref, computed, shallowReactive} from 'vue'
import {useI18n} from 'vue-i18n' import {useI18n} from 'vue-i18n'
import {useRoute, useRouter} from 'vue-router'
import debounce from 'lodash.debounce' import debounce from 'lodash.debounce'
import BaseButton from '@/components/base/BaseButton.vue' import BaseButton from '@/components/base/BaseButton.vue'
import CustomTransition from '@/components/misc/CustomTransition.vue' import CustomTransition from '@/components/misc/CustomTransition.vue'
import CreateEdit from '@/components/misc/create-edit.vue'
import {useBaseStore} from '@/stores/base' import {useBaseStore} from '@/stores/base'
import {useListStore} from '@/stores/lists' import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces'
import {useConfigStore} from '@/stores/config' import {useConfigStore} from '@/stores/config'
import BackgroundUnsplashService from '@/services/backgroundUnsplash' import BackgroundUnsplashService from '@/services/backgroundUnsplash'
import BackgroundUploadService from '@/services/backgroundUpload'
import ListService from '@/services/list'
import type BackgroundImageModel from '@/models/backgroundImage'
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
import {useTitle} from '@/composables/useTitle' import {useTitle} from '@/composables/useTitle'
import CreateEdit from '@/components/misc/create-edit.vue' import type {IBackgroundImage} from '@/modelTypes/IBackgroundImage'
import {success} from '@/message' import type {IList} from '@/modelTypes/IList'
import {getBlurHash} from '@/helpers/blurhash'
const SEARCH_DEBOUNCE = 300 const SEARCH_DEBOUNCE = 300
const {listId} = defineProps<{
listId: IList['id']
}>()
const {t} = useI18n({useScope: 'global'}) const {t} = useI18n({useScope: 'global'})
const baseStore = useBaseStore() const baseStore = useBaseStore()
const route = useRoute()
const router = useRouter()
useTitle(() => t('list.background.title')) useTitle(() => t('list.background.title'))
const backgroundService = shallowReactive(new BackgroundUnsplashService()) const backgroundService = shallowReactive(new BackgroundUnsplashService())
const backgroundSearchTerm = ref('') const backgroundSearchTerm = ref('')
const backgroundSearchResult = ref([]) const backgroundSearchResult = ref<IBackgroundImage[]>([])
const backgroundThumbs = ref<Record<string, string>>({}) const backgroundThumbs = ref<Record<string, string>>({})
const backgroundBlurHashes = ref<Record<string, string>>({}) const backgroundBlurHashes = ref<Record<string, string | undefined>>({})
const currentPage = ref(1) const currentPage = ref(1)
// We're using debounce to not search on every keypress but with a delay. // We're using debounce to not search on every keypress but with a delay.
@ -143,25 +141,23 @@ const debounceNewBackgroundSearch = debounce(newBackgroundSearch, SEARCH_DEBOUNC
trailing: true, trailing: true,
}) })
const backgroundUploadService = ref(new BackgroundUploadService())
const listService = ref(new ListService())
const listStore = useListStore() const listStore = useListStore()
const namespaceStore = useNamespaceStore()
const configStore = useConfigStore() const configStore = useConfigStore()
const unsplashBackgroundEnabled = computed(() => configStore.enabledBackgroundProviders.includes('unsplash')) const unsplashBackgroundEnabled = computed(() => configStore.enabledBackgroundProviders.includes('unsplash'))
const uploadBackgroundEnabled = computed(() => configStore.enabledBackgroundProviders.includes('upload')) const uploadBackgroundEnabled = computed(() => configStore.enabledBackgroundProviders.includes('upload'))
const currentList = computed(() => baseStore.currentList) const hasBackground = computed(() => baseStore.currentList?.backgroundInformation !== undefined)
const hasBackground = computed(() => baseStore.background !== null)
// Show the default collection of backgrounds // Show the default collection of backgrounds
newBackgroundSearch() newBackgroundSearch()
/**
* Reset a few things before searching to not break loading more photos.
*/
function newBackgroundSearch() { function newBackgroundSearch() {
if (!unsplashBackgroundEnabled.value) { if (!unsplashBackgroundEnabled.value) {
return return
} }
// This is an extra method to reset a few things when searching to not break loading more photos.
backgroundSearchResult.value = [] backgroundSearchResult.value = []
backgroundThumbs.value = {} backgroundThumbs.value = {}
searchBackgrounds() searchBackgrounds()
@ -171,11 +167,10 @@ async function searchBackgrounds(page = 1) {
currentPage.value = page currentPage.value = page
const result = await backgroundService.getAll({}, {s: backgroundSearchTerm.value, p: page}) const result = await backgroundService.getAll({}, {s: backgroundSearchTerm.value, p: page})
backgroundSearchResult.value = backgroundSearchResult.value.concat(result) backgroundSearchResult.value = backgroundSearchResult.value.concat(result)
result.forEach((background: BackgroundImageModel) => { result.forEach((background: IBackgroundImage) => {
getBlobFromBlurHash(background.blurHash) getBlurHash(background.blurHash).then((b) => {
.then((b) => { backgroundBlurHashes.value[background.id] = b
backgroundBlurHashes.value[background.id] = window.URL.createObjectURL(b) })
})
backgroundService.thumb(background).then(b => { backgroundService.thumb(background).then(b => {
backgroundThumbs.value[background.id] = b backgroundThumbs.value[background.id] = b
@ -183,47 +178,7 @@ async function searchBackgrounds(page = 1) {
}) })
} }
async function setBackground(backgroundId: string) {
// Don't set a background if we're in the process of setting one
if (backgroundService.loading) {
return
}
const list = await backgroundService.update({
id: backgroundId,
listId: route.params.listId,
})
await baseStore.handleSetCurrentList({list, forceUpdate: true})
namespaceStore.setListInNamespaceById(list)
listStore.setList(list)
success({message: t('list.background.success')})
}
const backgroundUploadInput = ref<HTMLInputElement | null>(null) const backgroundUploadInput = ref<HTMLInputElement | null>(null)
async function uploadBackground() {
if (backgroundUploadInput.value?.files?.length === 0) {
return
}
const list = await backgroundUploadService.value.create(
route.params.listId,
backgroundUploadInput.value?.files[0],
)
await baseStore.handleSetCurrentList({list, forceUpdate: true})
namespaceStore.setListInNamespaceById(list)
listStore.setList(list)
success({message: t('list.background.success')})
}
async function removeBackground() {
const list = await listService.value.removeBackground(currentList.value)
await baseStore.handleSetCurrentList({list, forceUpdate: true})
namespaceStore.setListInNamespaceById(list)
listStore.setList(list)
success({message: t('list.background.removeSuccess')})
router.back()
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -27,46 +27,37 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {computed, ref, watchEffect} from 'vue' import {ref, watch} from 'vue'
import {useTitle} from '@/composables/useTitle' import {useTitle} from '@/composables/useTitle'
import {useI18n} from 'vue-i18n' import {useI18n} from 'vue-i18n'
import {useRoute, useRouter} from 'vue-router'
import {success} from '@/message'
import TaskCollectionService from '@/services/taskCollection' import TaskCollectionService from '@/services/taskCollection'
import Loading from '@/components/misc/loading.vue' import Loading from '@/components/misc/loading.vue'
import {useListStore} from '@/stores/lists'
import {useList} from '@/stores/lists'
import type {IList} from '@/modelTypes/IList'
const props = defineProps<{
listId: IList['id']
}>()
const {t} = useI18n({useScope: 'global'}) const {t} = useI18n({useScope: 'global'})
const listStore = useListStore()
const route = useRoute()
const router = useRouter()
const totalTasks = ref<number | null>(null) const totalTasks = ref<number | null>(null)
watch(
const list = computed(() => listStore.getListById(route.params.listId)) () => props.listId,
async (currentListId) => {
watchEffect( if (!currentListId) {
() => {
if (!route.params.listId) {
return return
} }
const taskCollectionService = new TaskCollectionService() const taskCollectionService = new TaskCollectionService()
taskCollectionService.getAll({listId: route.params.listId}).then(() => { await taskCollectionService.getAll({listId: currentListId})
totalTasks.value = taskCollectionService.totalPages * taskCollectionService.resultCount totalTasks.value = taskCollectionService.totalPages * taskCollectionService.resultCount
})
}, },
{immediate: true},
) )
const {list, deleteList} = useList(listId)
useTitle(() => t('list.delete.title', {list: list?.value?.title})) useTitle(() => t('list.delete.title', {list: list?.value?.title}))
async function deleteList() {
if (!list.value) {
return
}
await listStore.deleteList(list.value)
success({message: t('list.delete.success')})
router.push({name: 'home'})
}
</script> </script>

View File

@ -3,8 +3,9 @@
:title="$t('list.duplicate.title')" :title="$t('list.duplicate.title')"
primary-icon="paste" primary-icon="paste"
:primary-label="$t('list.duplicate.label')" :primary-label="$t('list.duplicate.label')"
@primary="duplicateList" :primary-disabled="selectedNamespace === undefined"
:loading="listDuplicateService.loading" @primary="duplicateList(listId, selectedNamespace?.id)"
:loading="isDuplicatingList"
> >
<p>{{ $t('list.duplicate.text') }}</p> <p>{{ $t('list.duplicate.text') }}</p>
@ -12,7 +13,7 @@
:placeholder="$t('namespace.search')" :placeholder="$t('namespace.search')"
@search="findNamespaces" @search="findNamespaces"
:search-results="namespaces" :search-results="namespaces"
@select="selectNamespace" @select="(namespace) => selectNamespace(namespace as INamespace)"
label="title" label="title"
:search-delay="10" :search-delay="10"
/> />
@ -20,22 +21,23 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {ref, shallowReactive} from 'vue' import {ref, toRefs} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import {useI18n} from 'vue-i18n' import {useI18n} from 'vue-i18n'
import ListDuplicateService from '@/services/listDuplicateService'
import CreateEdit from '@/components/misc/create-edit.vue' import CreateEdit from '@/components/misc/create-edit.vue'
import Multiselect from '@/components/input/multiselect.vue' import Multiselect from '@/components/input/multiselect.vue'
import ListDuplicateModel from '@/models/listDuplicateModel'
import type {INamespace} from '@/modelTypes/INamespace' import type {INamespace} from '@/modelTypes/INamespace'
import type {IList} from '@/modelTypes/IList'
import {success} from '@/message'
import {useTitle} from '@/composables/useTitle' import {useTitle} from '@/composables/useTitle'
import {useNamespaceSearch} from '@/composables/useNamespaceSearch' import {useNamespaceSearch} from '@/composables/useNamespaceSearch'
import {useListStore} from '@/stores/lists' import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces'
const props = defineProps<{
listId: IList['id']
}>()
const {listId} = toRefs(props)
const {t} = useI18n({useScope: 'global'}) const {t} = useI18n({useScope: 'global'})
useTitle(() => t('list.duplicate.title')) useTitle(() => t('list.duplicate.title'))
@ -46,30 +48,12 @@ const {
} = useNamespaceSearch() } = useNamespaceSearch()
const selectedNamespace = ref<INamespace>() const selectedNamespace = ref<INamespace>()
function selectNamespace(namespace: INamespace) { function selectNamespace(namespace: INamespace) {
selectedNamespace.value = namespace selectedNamespace.value = namespace
} }
const route = useRoute() const {
const router = useRouter() duplicateList,
const listStore = useListStore() isDuplicatingList,
const namespaceStore = useNamespaceStore() } = useListStore()
const listDuplicateService = shallowReactive(new ListDuplicateService())
async function duplicateList() {
const listDuplicate = new ListDuplicateModel({
// FIXME: should be parameter
listId: route.params.listId,
namespaceId: selectedNamespace.value?.id,
})
const duplicate = await listDuplicateService.create(listDuplicate)
namespaceStore.addListToNamespace(duplicate.list)
listStore.setList(duplicate.list)
success({message: t('list.duplicate.success')})
router.push({name: 'list.index', params: {listId: duplicate.list.id}})
}
</script> </script>

View File

@ -3,7 +3,7 @@
:title="$t('list.edit.header')" :title="$t('list.edit.header')"
primary-icon="" primary-icon=""
:primary-label="$t('misc.save')" :primary-label="$t('misc.save')"
@primary="save" @primary="saveList"
:tertiary="$t('misc.delete')" :tertiary="$t('misc.delete')"
@tertiary="$router.push({ name: 'list.settings.delete', params: { id: listId } })" @tertiary="$router.push({ name: 'list.settings.delete', params: { id: listId } })"
> >
@ -13,7 +13,7 @@
<input <input
:class="{ 'disabled': isLoading}" :class="{ 'disabled': isLoading}"
:disabled="isLoading || undefined" :disabled="isLoading || undefined"
@keyup.enter="save" @keyup.enter="saveList"
class="input" class="input"
id="title" id="title"
:placeholder="$t('list.edit.titlePlaceholder')" :placeholder="$t('list.edit.titlePlaceholder')"
@ -33,7 +33,7 @@
<input <input
:class="{ 'disabled': isLoading}" :class="{ 'disabled': isLoading}"
:disabled="isLoading || undefined" :disabled="isLoading || undefined"
@keyup.enter="save" @keyup.enter="saveList"
class="input" class="input"
id="identifier" id="identifier"
:placeholder="$t('list.edit.identifierPlaceholder')" :placeholder="$t('list.edit.identifierPlaceholder')"
@ -70,8 +70,6 @@ export default { name: 'list-setting-edit' }
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
import type {PropType} from 'vue'
import {useRouter} from 'vue-router'
import {useI18n} from 'vue-i18n' import {useI18n} from 'vue-i18n'
import Editor from '@/components/input/AsyncEditor' import Editor from '@/components/input/AsyncEditor'
@ -80,29 +78,16 @@ import CreateEdit from '@/components/misc/create-edit.vue'
import type {IList} from '@/modelTypes/IList' import type {IList} from '@/modelTypes/IList'
import {useBaseStore} from '@/stores/base'
import {useList} from '@/stores/lists' import {useList} from '@/stores/lists'
import {useTitle} from '@/composables/useTitle' import {useTitle} from '@/composables/useTitle'
const props = defineProps({ const {listId} = defineProps<{
listId: { listId: IList['id']
type: Number as PropType<IList['id']>, }>()
required: true,
},
})
const router = useRouter()
const {t} = useI18n({useScope: 'global'}) const {t} = useI18n({useScope: 'global'})
const {list, save: saveList, isLoading} = useList(props.listId) const {list, saveList, isLoading} = useList(listId)
useTitle(() => list?.title ? t('list.edit.title', {list: list.title}) : '') useTitle(() => list.value.title ? t('list.edit.title', {list: list.value.title}) : '')
async function save() {
await saveList()
await useBaseStore().handleSetCurrentList({list})
router.back()
}
</script> </script>

View File

@ -18,7 +18,11 @@
/> />
</template> </template>
<link-sharing :list-id="listId" v-if="linkSharingEnabled" class="mt-4"/> <link-sharing
v-if="linkSharingEnabled"
class="mt-4"
:list-id="listId"
/>
</create-edit> </create-edit>
</template> </template>
@ -27,26 +31,28 @@ export default {name: 'list-setting-share'}
</script> </script>
<script lang="ts" setup> <script lang="ts" setup>
import {ref, computed, watchEffect} from 'vue' import {computed} from 'vue'
import {useRoute} from 'vue-router'
import {useI18n} from 'vue-i18n' import {useI18n} from 'vue-i18n'
import {useTitle} from '@vueuse/core' import {useTitle} from '@vueuse/core'
import ListService from '@/services/list'
import ListModel from '@/models/list'
import type {IList} from '@/modelTypes/IList'
import {RIGHTS} from '@/constants/rights'
import CreateEdit from '@/components/misc/create-edit.vue' import CreateEdit from '@/components/misc/create-edit.vue'
import LinkSharing from '@/components/sharing/linkSharing.vue' import LinkSharing from '@/components/sharing/linkSharing.vue'
import userTeam from '@/components/sharing/userTeam.vue' import userTeam from '@/components/sharing/userTeam.vue'
import {useBaseStore} from '@/stores/base' import {useAuthStore} from '@/stores/auth'
import {useConfigStore} from '@/stores/config' import {useConfigStore} from '@/stores/config'
import {useList} from '@/stores/lists'
import type {IList} from '@/modelTypes/IList'
const {listId} = defineProps<{
listId: IList['id']
}>()
const {t} = useI18n({useScope: 'global'}) const {t} = useI18n({useScope: 'global'})
const list = ref<IList>() const {list} = useList(listId)
const title = computed(() => list.value?.title const title = computed(() => list.value?.title
? t('list.share.title', {list: list.value.title}) ? t('list.share.title', {list: list.value.title})
: '', : '',
@ -54,21 +60,8 @@ const title = computed(() => list.value?.title
useTitle(title) useTitle(title)
const configStore = useConfigStore() const configStore = useConfigStore()
const authStore = useAuthStore()
const linkSharingEnabled = computed(() => configStore.linkSharingEnabled) const linkSharingEnabled = computed(() => configStore.linkSharingEnabled)
const userIsAdmin = computed(() => list?.value?.maxRight === RIGHTS.ADMIN) const userIsAdmin = computed(() => list.value.owner.id === authStore.info?.id)
async function loadList(listId: number) {
const listService = new ListService()
const newList = await listService.get(new ListModel({id: listId}))
await useBaseStore().handleSetCurrentList({list: newList})
list.value = newList
}
const route = useRoute()
const listId = computed(() => route.params.listId !== undefined
? parseInt(route.params.listId as string)
: undefined,
)
watchEffect(() => listId.value !== undefined && loadList(listId.value))
</script> </script>