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

View File

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

View File

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

View File

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

View File

@ -30,6 +30,13 @@
>
{{ $t('menu.unarchive') }}
</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 v-else>
<dropdown-item

View File

@ -2,8 +2,8 @@
<div
class="list-card"
:class="{
'has-light-text': background !== null,
'has-background': blurHashUrl !== '' || background !== null
'has-light-text': backgroundUrl !== null,
'has-background': blurHashUrl !== '' || backgroundUrl !== null
}"
:style="{
'border-left': list.hexColor ? `0.25rem solid ${list.hexColor}` : undefined,
@ -12,8 +12,8 @@
>
<div
class="list-background background-fade-in"
:class="{'is-visible': background}"
:style="{'background-image': background !== null ? `url(${background})` : undefined}"
:class="{'is-visible': backgroundUrl}"
:style="{'background-image': backgroundUrl !== null ? `url(${backgroundUrl})` : undefined}"
/>
<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 {useListBackground} from './useListBackground'
import {useListStore} from '@/stores/lists'
import {useListBackground, useListStore} from '@/stores/lists'
const props = defineProps({
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()
</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 {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 },
})
}

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 type {IList} from '@/modelTypes/IList'
import {LIST_ID} from '@/constants/lists'
export function getListTitle(l: IList) {
if (l.id === -1) {
if (l.id === LIST_ID.FAVORITES) {
return i18n.global.t('list.pseudo.favorites.title')
}
return l.title

View File

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

View File

@ -169,7 +169,6 @@
"title": "List Title",
"color": "Color",
"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…",
"searchSelect": "Click or press enter to select this list",
"shared": "Shared Lists",
@ -187,7 +186,8 @@
"unarchive": "Un-Archive this list",
"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.",
"success": "The list was successfully archived."
"successArchived": "The list has been successfully archived.",
"successUnarchived": "The list has been successfully unarchived."
},
"background": {
"title": "Set list background",

View File

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

View File

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

View File

@ -2,7 +2,6 @@ import {AuthenticatedHTTPFactory} from '@/helpers/fetcher'
import type {Method} from 'axios'
import {objectToSnakeCase} from '@/helpers/case'
import AbstractModel from '@/models/abstractModel'
import type {IAbstract} from '@/modelTypes/IAbstract'
import type {Right} from '@/constants/rights'
@ -15,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()
@ -75,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()) {
@ -87,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
@ -106,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
///////////////
@ -127,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]]
@ -139,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),
@ -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.
* 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.
@ -185,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)
}
//////////////
@ -224,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
}
@ -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 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.')
}
@ -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.
* 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()
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
@ -293,7 +263,7 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
responseType: 'blob',
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 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 === '') {
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)
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'])
@ -326,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()
}
@ -334,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.')
}
@ -345,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
@ -360,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
@ -378,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)
}
@ -25,6 +27,6 @@ export default class BackgroundUnsplashService extends AbstractService<IBackgrou
method: 'GET',
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 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,7 +1,10 @@
import AbstractService from './abstractService'
import ListModel from '@/models/list'
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'
export default class ListService extends AbstractService<IList> {
@ -34,29 +37,35 @@ export default class ListService extends AbstractService<IList> {
return model
}
beforeCreate(list) {
beforeCreate(list: IList) {
list.hexColor = colorFromHex(list.hexColor)
return list
}
async background(list: Pick<IList, 'id' | 'backgroundInformation'>) {
if (list.backgroundInformation === null) {
return ''
async loadBackground(list: Pick<IList, 'id' | 'backgroundInformation'>) {
if (list === null || !list.backgroundInformation) {
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'>) {
const cancel = this.setLoading()
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
} finally {
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) {
const attachmentService = new AttachmentService()
model.attachments.map(a => {

View File

@ -1,10 +1,7 @@
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'
import {useMenuActive} from '@/composables/useMenuActive'
@ -21,8 +18,6 @@ export const useBaseStore = defineStore('base', () => {
id: 0,
isArchived: false,
}))
const background = ref('')
const blurHash = ref('')
const hasTasks = ref(false)
const keyboardShortcutsActive = ref(false)
@ -62,14 +57,6 @@ export const useBaseStore = defineStore('base', () => {
quickActionsActive.value = value
}
function setBackground(newBackground: string) {
background.value = newBackground
}
function setBlurHash(newBlurHash: string) {
blurHash.value = newBlurHash
}
function setLogoVisible(visible: boolean) {
logoVisible.value = visible
}
@ -78,43 +65,14 @@ export const useBaseStore = defineStore('base', () => {
ready.value = value
}
async function handleSetCurrentList(
{list, forceUpdate = false}: {list: IList | null, forceUpdate?: boolean},
function handleSetCurrentList(
{list}: {list: IList | null},
) {
if (list === null) {
setCurrentList({})
setBackground('')
setBlurHash('')
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)
}
@ -129,8 +87,6 @@ export const useBaseStore = defineStore('base', () => {
loading: readonly(loading),
ready: readonly(ready),
currentList: readonly(currentList),
background: readonly(background),
blurHash: readonly(blurHash),
hasTasks: readonly(hasTasks),
keyboardShortcutsActive: readonly(keyboardShortcutsActive),
quickActionsActive: readonly(quickActionsActive),
@ -142,8 +98,6 @@ export const useBaseStore = defineStore('base', () => {
setHasTasks,
setKeyboardShortcutsActive,
setQuickActionsActive,
setBackground,
setBlurHash,
setLogoVisible,
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 {useI18n} from 'vue-i18n'
import ListService from '@/services/list'
import {setModuleLoading} from '@/stores/helper'
import {removeListFromHistory} from '@/modules/listHistory'
import {createNewIndexer} from '@/indexes'
import {useNamespaceStore} from './namespaces'
import {useRouter} from 'vue-router'
import router from '@/router'
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 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 {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 FavoriteListsNamespace = -2
export interface ListState {
[id: IList['id']]: IList
}
@ -35,7 +48,9 @@ export const useListStore = defineStore('list', () => {
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(() => {
@ -53,7 +68,7 @@ export const useListStore = defineStore('list', () => {
?.filter(value => value > 0)
.map(id => lists.value[id])
.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) {
// The favorites pseudo list is always favorite
// Archived lists cannot be marked favorite
if (list.id === -1 || list.isArchived) {
if (list.id === LIST_ID.FAVORITES || list.isArchived) {
return
}
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) {
const cancel = setModuleLoading(setIsLoading)
const listService = new ListService()
@ -113,6 +137,8 @@ export const useListStore = defineStore('list', () => {
const cancel = setModuleLoading(setIsLoading)
const listService = new ListService()
const oldList = cloneDeep(getListById.value(list.id) as IList)
try {
await listService.update(list)
setList(list)
@ -120,24 +146,18 @@ export const useListStore = defineStore('list', () => {
// 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
const newList = {
...list,
namespaceId: FavoriteListsNamespace,
}
namespaceStore.removeListFromNamespaceById(newList)
const newList = {...list}
if (list.isFavorite) {
namespaceStore.addListToNamespace(newList)
namespaceStore.addListToNamespace(newList, NAMESPACE_ID.FAVORITES)
} else {
namespaceStore.removeListFromNamespaceById(newList, NAMESPACE_ID.FAVORITES)
}
namespaceStore.loadNamespacesIfFavoritesDontExist()
namespaceStore.removeFavoritesNamespaceIfEmpty()
return newList
} catch (e) {
// Reset the list state to the initial one to avoid confusion for the user
setList({
...list,
isFavorite: !list.isFavorite,
})
setList(oldList)
throw e
} finally {
cancel()
@ -150,7 +170,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
@ -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 {
isLoading: readonly(isLoading),
lists: readonly(lists),
@ -169,43 +308,185 @@ export const useListStore = defineStore('list', () => {
setList,
setLists,
removeListById,
toggleListFavorite,
// crud
loadList,
createList,
updateList,
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
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')})
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 {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 {IList} from '@/modelTypes/IList'
import {NAMESPACE_ID} from '@/constants/namespaces'
import {setModuleLoading} from '@/stores/helper'
import {useListStore} from '@/stores/lists'
import {createNewIndexer} from '@/indexes'
import NamespaceService from '@/services/namespace'
const {add, remove, search, update} = createNewIndexer('namespaces', ['title', 'description'])
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) {
if (namespaces.value[n].id === list.namespaceId) {
if (namespaces.value[n].id === (namespaceId ?? list.namespaceId)) {
namespaces.value[n].lists.push(list)
return
}
}
}
function removeListFromNamespaceById(list: IList) {
function removeListFromNamespaceById(list: IList, namespaceId?: INamespace['id']) {
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.
// 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) {
if (namespaces.value[n].lists[l].id === list.id) {
namespaces.value[n].lists.splice(l, 1)
@ -169,14 +173,20 @@ export const useNamespaceStore = defineStore('namespace', () => {
function loadNamespacesIfFavoritesDontExist() {
// 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 loadNamespaces()
}
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)
}
}

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,12 +1,22 @@
<template>
<modal
@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>
<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>
</modal>
</template>
@ -16,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,42 +100,40 @@ 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'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import CreateEdit from '@/components/misc/create-edit.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'
import {getBlurHash} from '@/helpers/blurhash'
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,23 @@ const debounceNewBackgroundSearch = debounce(newBackgroundSearch, SEARCH_DEBOUNC
trailing: true,
})
const backgroundUploadService = ref(new BackgroundUploadService())
const listService = ref(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)
const hasBackground = computed(() => baseStore.currentList?.backgroundInformation !== undefined)
// 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 +167,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) => {
getBlurHash(background.blurHash).then((b) => {
backgroundBlurHashes.value[background.id] = b
})
backgroundService.thumb(background).then(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)
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,46 +27,37 @@
</template>
<script setup lang="ts">
import {computed, ref, watchEffect} 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<{
listId: IList['id']
}>()
const {t} = useI18n({useScope: 'global'})
const listStore = useListStore()
const route = useRoute()
const router = useRouter()
const totalTasks = ref<number | null>(null)
const list = computed(() => listStore.getListById(route.params.listId))
watchEffect(
() => {
if (!route.params.listId) {
watch(
() => props.listId,
async (currentListId) => {
if (!currentListId) {
return
}
const taskCollectionService = new TaskCollectionService()
taskCollectionService.getAll({listId: route.params.listId}).then(() => {
totalTasks.value = taskCollectionService.totalPages * taskCollectionService.resultCount
})
await taskCollectionService.getAll({listId: currentListId})
totalTasks.value = taskCollectionService.totalPages * taskCollectionService.resultCount
},
{immediate: true},
)
const {list, deleteList} = useList(listId)
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>

View File

@ -3,8 +3,9 @@
:title="$t('list.duplicate.title')"
primary-icon="paste"
:primary-label="$t('list.duplicate.label')"
@primary="duplicateList"
:loading="listDuplicateService.loading"
: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"
/>
@ -20,22 +21,23 @@
</template>
<script setup lang="ts">
import {ref, shallowReactive} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import {ref, toRefs} from 'vue'
import {useI18n} from 'vue-i18n'
import ListDuplicateService from '@/services/listDuplicateService'
import CreateEdit from '@/components/misc/create-edit.vue'
import Multiselect from '@/components/input/multiselect.vue'
import ListDuplicateModel from '@/models/listDuplicateModel'
import type {INamespace} from '@/modelTypes/INamespace'
import type {IList} from '@/modelTypes/IList'
import {success} from '@/message'
import {useTitle} from '@/composables/useTitle'
import {useNamespaceSearch} from '@/composables/useNamespaceSearch'
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'})
useTitle(() => t('list.duplicate.title'))
@ -46,30 +48,12 @@ const {
} = useNamespaceSearch()
const selectedNamespace = ref<INamespace>()
function selectNamespace(namespace: INamespace) {
selectedNamespace.value = namespace
}
const route = useRoute()
const router = useRouter()
const listStore = useListStore()
const namespaceStore = useNamespaceStore()
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}})
}
const {
duplicateList,
isDuplicatingList,
} = 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,29 +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 props = defineProps({
listId: {
type: Number as PropType<IList['id']>,
required: true,
},
})
const router = useRouter()
const {listId} = defineProps<{
listId: IList['id']
}>()
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}) : '')
async function save() {
await saveList()
await useBaseStore().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>