feature/feat-useList-composable #2589

Closed
dpschen wants to merge 7 commits from dpschen/frontend:feature/feat-useList-composable into main
20 changed files with 491 additions and 395 deletions
Showing only changes of commit d2870ce493 - Show all commits

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import {i18n} from '@/i18n'
import type {INamespace} from '@/modelTypes/INamespace'
import {NAMESPACE_ID} from "@/constants/namespaces"
import {NAMESPACE_ID} from '@/constants/namespaces'
export const getNamespaceTitle = (n: INamespace) => {
if (n.id === NAMESPACE_ID.SHARED_LIST) {

View File

@ -300,6 +300,7 @@ const router = createRouter({
meta: {
showAsModal: true,
},
props: route => ({ listId: Number(route.params.listId as string) }),
},
{
path: '/lists/:listId/settings/share',

View File

@ -14,6 +14,11 @@ interface Paths {
reset?: string
}
/**
* The replacement pattern for url paths, can be overwritten by implementations.
*/
const ROUTE_PARAMETER_PATTERN = /{([^}]+)}/
function convertObject(o: Record<string, unknown>) {
if (o instanceof Date) {
return o.toISOString()
@ -74,10 +79,8 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
this.http.interceptors.request.use((config) => {
switch (config.method) {
case 'post':
if (this.useUpdateInterceptor()) {
config.data = this.beforeUpdate(config.data)
config.data = objectToSnakeCase(config.data)
}
config.data = this.beforeUpdate(config.data)
config.data = objectToSnakeCase(config.data)
break
case 'put':
if (this.useCreateInterceptor()) {
@ -86,10 +89,7 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
}
break
case 'delete':
if (this.useDeleteInterceptor()) {
config.data = this.beforeDelete(config.data)
config.data = objectToSnakeCase(config.data)
}
config.data = objectToSnakeCase(config.data)
break
}
return config
@ -105,20 +105,6 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
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
///////////////
@ -126,10 +112,9 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
/**
* 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> = {}
let pattern = this.getRouteParameterPattern()
pattern = new RegExp(pattern instanceof RegExp ? pattern.source : pattern, 'g')
const pattern = new RegExp(ROUTE_PARAMETER_PATTERN, 'g')
for (let parameter; (parameter = pattern.exec(route)) !== null;) {
replace$$1[parameter[0]] = parameters[parameter[1]]
@ -138,17 +123,10 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
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.
*/
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)
return Object.entries(replacements).reduce(
(result, [parameter, value]) => result.replace(parameter, value as string),
@ -157,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.
* It has the timeout to prevent the loading indicator from showing for only a blink of an eye in the
* Sets the loading variable to true, after a timeout of 100ms.
* 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.
* 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.
@ -184,36 +162,36 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
* 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.
*/
modelFactory(data : Partial<Model> = {}) {
return {...data} as Model
modelFactory<DefaultModel extends {[key: string]: unknown} = Partial<Model>>(data: DefaultModel = {}) {
return {...data} as DefaultModel
}
/**
* This is the model factory for get requests.
*/
modelGetFactory(data : Partial<Model> = {}) {
return this.modelFactory(data)
modelGetFactory<GetModel extends {[key: string]: unknown} = Partial<Model>>(data: GetModel = {}) {
return this.modelFactory<GetModel>(data)
}
/**
* This is the model factory for get all requests.
*/
modelGetAllFactory(data : Partial<Model>) {
return this.modelFactory(data)
modelGetAllFactory<GetAllModel extends {[key: string]: unknown} = Partial<Model>>(data: GetAllModel = {}) {
return this.modelFactory<GetAllModel>(data)
}
/**
* This is the model factory for create requests.
*/
modelCreateFactory(data : Partial<Model>) {
return this.modelFactory(data)
modelCreateFactory<CreateModel extends {[key: string]: unknown} = Partial<Model>>(data: CreateModel = {}) {
return this.modelFactory<CreateModel>(data)
}
/**
* This is the model factory for update requests.
*/
modelUpdateFactory(data : Partial<Model>) {
return this.modelFactory(data)
modelUpdateFactory<UpdateModel extends {[key: string]: unknown} = Partial<Model>>(data: UpdateModel = {}) {
return this.modelFactory<UpdateModel>(data)
}
//////////////
@ -223,28 +201,21 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
/**
* Default preprocessor for get requests
*/
beforeGet(model : Model) {
beforeGet(model: Model) {
return model
}
/**
* Default preprocessor for create requests
*/
beforeCreate(model : Model) {
beforeCreate(model: Model) {
return model
}
/**
* Default preprocessor for update requests
*/
beforeUpdate(model : Model) {
return model
}
/**
* Default preprocessor for delete requests
*/
beforeDelete(model : Model) {
beforeUpdate(model: Model) {
return model
}
@ -257,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 params Optional query parameters
*/
get(model : Model, params = {}) {
get(model: Model, params = {}) {
if (this.paths.get === '') {
throw new Error('This model is not able to get data.')
}
@ -269,14 +240,14 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
* This is a more abstract implementation which only does a get request.
* Services which need more flexibility can use this.
*/
async getM(url : string, model : Model = this.modelGetFactory(), params: Record<string, unknown> = {}) {
async getM(url : string, model: Model = this.modelGetFactory(), params: Record<string, unknown> = {}) {
const cancel = this.setLoading()
model = this.beforeGet(model)
const finalUrl = this.getReplacedRoute(url, model)
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)
result.maxRight = Number(response.headers['x-max-right']) as Right
return result
@ -314,7 +285,7 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
const finalUrl = this.getReplacedRoute(this.paths.getAll, model)
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.totalPages = Number(response.headers['x-pagination-total-pages'])
@ -325,7 +296,7 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
if (Array.isArray(response.data)) {
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 {
cancel()
}
@ -333,9 +304,8 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
/**
* 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 === '') {
throw new Error('This model is not able to create data.')
}
@ -344,7 +314,7 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
const finalUrl = this.getReplacedRoute(this.paths.create, model)
try {
const response = await this.http.put(finalUrl, model)
const response = await this.http.put<M>(finalUrl, model)
const result = this.modelCreateFactory(response.data)
if (typeof model.maxRight !== 'undefined') {
result.maxRight = model.maxRight
@ -359,11 +329,11 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
* An abstract implementation to send post requests.
* 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()
try {
const response = await this.http.post(url, model)
const response = await this.http.post<PostModel>(url, model)
const result = this.modelUpdateFactory(response.data)
if (typeof model.maxRight !== 'undefined') {
result.maxRight = model.maxRight
@ -377,7 +347,7 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
/**
* Performs a post request to the update url
*/
update(model : Model) {
update<M = Model>(model: M) {
if (this.paths.update === '') {
throw new Error('This model is not able to update data.')
}

View File

@ -1,7 +1,9 @@
import AbstractService from './abstractService'
import BackgroundImageModel from '../models/backgroundImage'
import type {IBackgroundImage} from '@/modelTypes/IBackgroundImage'
import AbstractService from '@/services/abstractService'
import BackgroundImageModel from '@/models/backgroundImage'
import ListModel from '@/models/list'
import type { IBackgroundImage } from '@/modelTypes/IBackgroundImage'
export default class BackgroundUnsplashService extends AbstractService<IBackgroundImage> {
constructor() {
@ -11,7 +13,7 @@ export default class BackgroundUnsplashService extends AbstractService<IBackgrou
})
}
modelFactory(data: Partial<IBackgroundImage>) {
modelFactory(data) {
return new BackgroundImageModel(data)
}

View File

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

View File

@ -1,23 +1,12 @@
import {computed, ref, shallowReactive, unref, watch} from 'vue'
import {useRouter} from 'vue-router'
import {useI18n} from 'vue-i18n'
import type {MaybeRef} from '@vueuse/core'
import type {IList} from '@/modelTypes/IList'
import type {INamespace} from '@/modelTypes/INamespace'
import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces'
import AbstractService from '@/services/abstractService'
import TaskService from '@/services/task'
import ListDuplicateService from '@/services/listDuplicateService'
import ListModel from '@/models/list'
import ListDuplicateModel from '@/models/listDuplicateModel'
import {colorFromHex} from '@/helpers/color/colorFromHex'
import {success} from '@/message'
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
export default class ListService extends AbstractService<IList> {
constructor() {
@ -54,17 +43,35 @@ export default class ListService extends AbstractService<IList> {
return list
}
async background(list: Pick<IList, 'id' | 'backgroundInformation'>) {
if (list.backgroundInformation === null) {
return ''
// FIXME: move out of service
async getBlurHash(blurHashString: IList['backgroundBlurHash']) {
if (!blurHashString) {
return
}
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
const blurHashBlob = await getBlobFromBlurHash(blurHashString)
if (!blurHashBlob) {
return
}
return URL.createObjectURL(blurHashBlob)
}
const response = await this.http({
url: `/lists/${list.id}/background`,
method: 'GET',
responseType: 'blob',
})
return window.URL.createObjectURL(new Blob([response.data]))
async loadBackground(list: Pick<IList, 'id' | 'backgroundInformation'>) {
if (list === null || !list.backgroundInformation) {
return
}
const cancel = this.setLoading()
try {
const response = await this.http({
url: `/lists/${list.id}/background`,
method: 'GET',
responseType: 'blob',
})
return URL.createObjectURL(new Blob([response.data]))
} finally {
cancel()
}
}
async removeBackground(list: Pick<IList, 'id'>) {
@ -77,59 +84,4 @@ export default class ListService extends AbstractService<IList> {
cancel()
}
}
}
export function useList(listId?: MaybeRef<IList['id']>) {
const {t} = useI18n({useScope: 'global'})
const router = useRouter()
const listStore = useListStore()
const namespaceStore = useNamespaceStore()
const currentListId = computed(() => unref(listId))
const list = ref<IList>(new ListModel())
const listDuplicateService = shallowReactive(new ListDuplicateService())
const isDuplicatingList = computed(() => listDuplicateService.loading)
// load list
watch(() => unref(listId), async (watchedListId) => {
if (watchedListId === undefined) {
return
}
list.value = listStore.getListById(watchedListId) || list.value
// TODO: load list from server
}, {immediate: true})
async function duplicateList(namespaceId: INamespace['id']) {
const listDuplicate = new ListDuplicateModel({
listId: currentListId.value,
namespaceId: namespaceId,
})
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}})
}
async function deleteList() {
if (!list.value) {
return
}
await listStore.deleteList(list.value)
success({message: t('list.delete.success')})
router.push({name: 'home'})
}
return {
duplicateList,
isDuplicatingList,
}
}

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) {
const attachmentService = new AttachmentService()
model.attachments.map(a => {

View File

@ -1,8 +1,6 @@
import { readonly, ref} from 'vue'
import {defineStore, acceptHMRUpdate} from 'pinia'
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
import ListModel from '@/models/list'
import ListService from '@/services/list'
import {checkAndSetApiUrl} from '@/helpers/checkAndSetApiUrl'
@ -90,20 +88,30 @@ export const useBaseStore = defineStore('base', () => {
// 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 (
(list.id !== currentList.value?.id || forceUpdate) &&
list.backgroundInformation
) {
try {
const listService = new ListService()
const blurHashPromise = listService.getBlurHash(list.backgroundBlurHash).then(blurHash => {
if (blurHash) {
setBlurHash(window.URL.createObjectURL(blurHash))
setBlurHash(blurHash)
}
})
const backgroundPromise = listService.loadBackground(list).then(background => {
if (background === undefined) {
throw new Error()
}
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)
}
})
await Promise.all([
blurHashPromise,
backgroundPromise,
])
} catch (e) {
console.error('Error getting background image for list', list.id, e)
}
}

View File

@ -1,22 +1,32 @@
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 {useI18n} from 'vue-i18n'
import {useRouter} from 'vue-router'
import type {IList} from '@/modelTypes/IList'
import type {INamespace} from '@/modelTypes/INamespace'
import type {IBackgroundImage} from '@/modelTypes/IBackgroundImage'
import {LIST_ID} from '@/constants/lists'
import {NAMESPACE_ID} from "@/constants/namespaces"
import {NAMESPACE_ID} from '@/constants/namespaces'
import {useNamespaceStore} from '@/stores/namespaces'
import {useBaseStore} from '@/stores/base'
import ListService from '@/services/list'
import ListDuplicateService from '@/services/listDuplicateService'
import BackgroundUnsplashService from '@/services/backgroundUnsplash'
import ListModel from '@/models/list'
import ListDuplicateModel from '@/models/listDuplicateModel'
import {removeListFromHistory} from '@/modules/listHistory'
import ListService from '@/services/list'
import {setModuleLoading} from '@/stores/helper'
import {useBaseStore} from './base'
import {useNamespaceStore} from './namespaces'
import {createNewIndexer} from '@/indexes'
import {success} from '@/message'
import { i18n } from '@/i18n'
import BackgroundUploadService from '@/services/backgroundUpload'
const {add, remove, search, update} = createNewIndexer('lists', ['title', 'description'])
@ -25,6 +35,8 @@ export interface ListState {
}
export const useListStore = defineStore('list', () => {
const router = useRouter()
const baseStore = useBaseStore()
const namespaceStore = useNamespaceStore()
@ -55,7 +67,7 @@ export const useListStore = defineStore('list', () => {
?.filter(value => value > 0)
.map(id => lists.value[id])
.filter(list => list.isArchived === includeArchived)
|| []
|| [] as IList[]
}
})
@ -79,11 +91,6 @@ export const useListStore = defineStore('list', () => {
})
}
function removeListById(list: IList) {
remove(list)
delete lists.value[list.id]
}
function toggleListFavorite(list: IList) {
// The favorites pseudo list is always favorite
// Archived lists cannot be marked favorite
@ -96,6 +103,20 @@ export const useListStore = defineStore('list', () => {
})
}
async function loadList(listId: List['id']) {
const cancel = setModuleLoading(this, setIsLoading)
dpschen marked this conversation as resolved Outdated

remove 'this'

remove 'this'

Was already fixed

Was already fixed
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) {
const cancel = setModuleLoading(setIsLoading)
const listService = new ListService()
@ -152,7 +173,8 @@ export const useListStore = defineStore('list', () => {
try {
const response = await listService.delete(list)
removeListById(list)
remove(list)
delete lists.value[list.id]
namespaceStore.removeListFromNamespaceById(list)
removeListFromHistory({id: list.id})
return response
@ -161,6 +183,118 @@ export const useListStore = defineStore('list', () => {
}
}
async function archiveList(list: IList) {
try {
const newList = await updateList({
...list,
isArchived: !list.isArchived,
})
baseStore.setCurrentList(newList)
success({message: i18n.global.t('list.archive.success')})
} 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
await baseStore.handleSetCurrentList({list, forceUpdate: true})
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)
await baseStore.handleSetCurrentList({list, forceUpdate: true})
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)
await baseStore.handleSetCurrentList({list, forceUpdate: true})
namespaceStore.setListInNamespaceById(list)
setList(list)
success({message: i18n.global.t('list.background.removeSuccess')})
router.back()
}
async function loadListBackground(list: IList) {
const result = {
blurHash: '',
background: '',
}
try {
const listService = new ListService()
const blurHashPromise = listService.getBlurHash(list.backgroundBlurHash).then(blurHash => {
if (blurHash) {
result.blurHash = blurHash
}
})
const backgroundPromise = listService.loadBackground(list).then(background => {
if (background === undefined) {
throw new Error()
}
result.background = background
})
await Promise.all([
blurHashPromise,
backgroundPromise,
])
} catch (e) {
console.error('Error getting background image for list', list.id, e)
}
return result
}
return {
isLoading: readonly(isLoading),
lists: readonly(lists),
@ -171,43 +305,168 @@ export const useListStore = defineStore('list', () => {
setList,
setLists,
removeListById,
toggleListFavorite,
// crud
loadList,
createList,
updateList,
deleteList,
archiveList,
//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
if (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')})
await 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)
await useBaseStore().handleSetCurrentList({list: list.value, forceUpdate: true})
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(listId?: MaybeRef<IList['id']>) {
const {
isLoading,
list,
currentListId,
} = useListBase(listId)
const blurHash = ref<string>()
const background = ref<string>()
const listStore = useListStore()
watch(
currentListId,
async (id) => {
if (id === undefined || isLoading) {
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 result = await listStore.loadListBackground(list.value)
blurHash.value = result.blurHash
background.value = result.background
},
{immediate: true},
)
return {
blurHash,
background,
}
}

View File

@ -4,7 +4,7 @@ import {defineStore, acceptHMRUpdate} from 'pinia'
import type {INamespace} from '@/modelTypes/INamespace'
import type {IList} from '@/modelTypes/IList'
import {NAMESPACE_ID} from "@/constants/namespaces"
import {NAMESPACE_ID} from '@/constants/namespaces'
import {setModuleLoading} from '@/stores/helper'
import {useListStore} from '@/stores/lists'

View File

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

View File

@ -1,7 +1,7 @@
<template>
<modal
@close="$router.back()"
@submit="archiveList()"
@submit="listStore.archiveList(list)"
>
<template #header>
<span>{{
@ -26,34 +26,21 @@ export default {name: 'list-setting-archive'}
</script>
<script setup lang="ts">
import {computed} from 'vue'
import {useRouter, useRoute} from 'vue-router'
import {useI18n} from 'vue-i18n'
import {success} from '@/message'
import {useTitle} from '@/composables/useTitle'
import {useBaseStore} from '@/stores/base'
import {useListStore} from '@/stores/lists'
import {useList, useListStore} from '@/stores/lists'
import type {IList} from '@/modelTypes/IList'
const {listId} = defineProps<{
listId: IList['id']
}>()
const {t} = useI18n({useScope: 'global'})
const listStore = useListStore()
const router = useRouter()
const route = useRoute()
const {list} = useList(listId)
const list = computed(() => listStore.getListById(route.params.listId))
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>

View File

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

View File

@ -27,15 +27,13 @@
</template>
<script setup lang="ts">
import {computed, ref, watch} from 'vue'
import {ref, watch} from 'vue'
import {useTitle} from '@/composables/useTitle'
import {useI18n} from 'vue-i18n'
import {useRoute, useRouter} from 'vue-router'
import {success} from '@/message'
import TaskCollectionService from '@/services/taskCollection'
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<{
@ -43,8 +41,6 @@ const props = defineProps<{
}>()
const {t} = useI18n({useScope: 'global'})
const listStore = useListStore()
const router = useRouter()
const totalTasks = ref<number | null>(null)
watch(
@ -62,13 +58,5 @@ watch(
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'})
}
const {list, deleteList} = useList(listId)
</script>

View File

@ -3,7 +3,8 @@
:title="$t('list.duplicate.title')"
primary-icon="paste"
:primary-label="$t('list.duplicate.label')"
@primary="duplicateList"
:primary-disabled="selectedNamespace === undefined"
@primary="duplicateList(listId, selectedNamespace?.id)"
:loading="isDuplicatingList"
>
<p>{{ $t('list.duplicate.text') }}</p>
@ -12,7 +13,7 @@
:placeholder="$t('namespace.search')"
@search="findNamespaces"
:search-results="namespaces"
@select="selectNamespace"
@select="(namespace) => selectNamespace(namespace as INamespace)"
label="title"
:search-delay="10"
/>
@ -31,7 +32,7 @@ import type {IList} from '@/modelTypes/IList'
import {useTitle} from '@/composables/useTitle'
import {useNamespaceSearch} from '@/composables/useNamespaceSearch'
import {useList} from '@/services/list'
import {useListStore} from '@/stores/lists'
const props = defineProps<{
listId: IList['id']
@ -54,5 +55,5 @@ function selectNamespace(namespace: INamespace) {
const {
duplicateList,
isDuplicatingList,
} = useList(listId)
} = useListStore()
</script>

View File

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

View File

@ -18,7 +18,11 @@
/>
</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>
</template>
@ -27,26 +31,28 @@ export default {name: 'list-setting-share'}
</script>
<script lang="ts" setup>
import {ref, computed, watchEffect} from 'vue'
import {useRoute} from 'vue-router'
import {computed} from 'vue'
import {useI18n} from 'vue-i18n'
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 LinkSharing from '@/components/sharing/linkSharing.vue'
import userTeam from '@/components/sharing/userTeam.vue'
import {useBaseStore} from '@/stores/base'
import {useAuthStore} from '@/stores/auth'
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 list = ref<IList>()
const {list} = useList(listId)
const title = computed(() => list.value?.title
? t('list.share.title', {list: list.value.title})
: '',
@ -54,21 +60,8 @@ const title = computed(() => list.value?.title
useTitle(title)
const configStore = useConfigStore()
const authStore = useAuthStore()
const linkSharingEnabled = computed(() => configStore.linkSharingEnabled)
const userIsAdmin = computed(() => list?.value?.maxRight === RIGHTS.ADMIN)
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))
const userIsAdmin = computed(() => list.value.owner.id === authStore.info?.id)
</script>