WIP: List composable

This commit is contained in:
Dominik Pschenitschni 2022-10-05 14:41:07 +02:00
parent c3c9960f1c
commit 6722601467
Signed by: dpschen
GPG Key ID: B257AC0149F43A77
21 changed files with 686 additions and 395 deletions

View File

@ -0,0 +1,195 @@
<template>
<router-link
:class="{
'has-light-text': !colorIsDark(list.hexColor) || background !== null,
'has-background': blurHashUrl !== '' || background !== null,
}"
:style="{
'background-color': list.hexColor,
'background-image': blurHashUrl !== null ? `url(${blurHashUrl})` : false,
}"
:to="{ name: 'list.index', params: { listId: list.id} }"
class="list-card"
v-if="list !== null && (showArchived ? true : !list.isArchived)"
>
<div
class="list-background background-fade-in"
:class="{'is-visible': background}"
:style="{'background-image': background !== null ? `url(${background})` : undefined}"
/>
<div class="list-content">
<span class="is-archived" v-if="list.isArchived">
{{ $t('namespace.archived') }}
</span>
<BaseButton
v-else
:class="{'is-favorite': list.isFavorite}"
@click.stop="listStore.toggleListFavorite(list)"
class="favorite"
>
<icon :icon="list.isFavorite ? 'star' : ['far', 'star']"/>
</BaseButton>
<div class="title">{{ list.title }}</div>
</div>
</router-link>
</template>
<script lang="ts" setup>
import type {PropType} from 'vue'
import {colorIsDark} from '@/helpers/color/colorIsDark'
import BaseButton from '@/components/base/BaseButton.vue'
import type {IList} from '@/modelTypes/IList'
import {useListBackground, useListStore} from '@/stores/lists'
const {list} = defineProps({
list: {
type: Object as PropType<IList>,
required: true,
},
showArchived: {
default: false,
type: Boolean,
},
})
const listStore = useListStore()
const {background, blurHash: blurHashUrl} = useListBackground(list.id)
</script>
<style lang="scss" scoped>
.list-card {
cursor: pointer;
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
height: $list-height;
background: var(--white);
margin: 0 $list-spacing $list-spacing 0;
border-radius: $radius;
box-shadow: var(--shadow-sm);
transition: box-shadow $transition;
position: relative;
overflow: hidden;
&.has-light-text .title {
color: var(--grey-100) !important;
}
&.has-background,
.list-background {
background-size: cover;
background-repeat: no-repeat;
background-position: center;
}
&.has-background .title {
text-shadow: 0 0 10px var(--black), 1px 1px 5px var(--grey-700), -1px -1px 5px var(--grey-700);
color: var(--white);
}
.list-background {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
&:hover {
box-shadow: var(--shadow-md);
}
&:active,
&:focus,
&:focus:not(:active) {
box-shadow: var(--shadow-xs) !important;
}
@media screen and (min-width: $widescreen) {
&:nth-child(#{$lists-per-row}n) {
margin-right: 0;
}
}
@media screen and (max-width: $widescreen) and (min-width: $tablet) {
$lists-per-row: 3;
& {
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
}
&:nth-child(#{$lists-per-row}n) {
margin-right: 0;
}
}
@media screen and (max-width: $tablet) {
$lists-per-row: 2;
& {
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
}
&:nth-child(#{$lists-per-row}n) {
margin-right: 0;
}
}
@media screen and (max-width: $mobile) {
$lists-per-row: 1;
& {
width: 100%;
margin-right: 0;
}
}
.list-content {
display: flex;
align-content: space-between;
flex-wrap: wrap;
padding: 1rem;
position: absolute;
height: 100%;
width: 100%;
.is-archived {
font-size: .75rem;
}
.favorite {
margin-left: auto;
transition: opacity $transition, color $transition;
opacity: 0;
display: block;
&:hover,
&.is-favorite {
color: var(--warning);
}
}
.favorite.is-favorite,
&:hover .favorite {
opacity: 1;
}
.title {
align-self: flex-end;
font-family: $vikunja-font;
font-weight: 400;
font-size: 1.5rem;
color: var(--text);
width: 100%;
margin-bottom: 0;
max-height: calc(100% - 2rem); // 1rem padding, 1rem height of the "is archived" badge
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
}
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,6 +14,11 @@ interface Paths {
reset?: string reset?: string
} }
/**
* The replacement pattern for url paths, can be overwritten by implementations.
*/
const ROUTE_PARAMETER_PATTERN = /{([^}]+)}/
function convertObject(o: Record<string, unknown>) { function convertObject(o: Record<string, unknown>) {
if (o instanceof Date) { if (o instanceof Date) {
return o.toISOString() return o.toISOString()
@ -74,10 +79,8 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
this.http.interceptors.request.use((config) => { this.http.interceptors.request.use((config) => {
switch (config.method) { switch (config.method) {
case 'post': case 'post':
if (this.useUpdateInterceptor()) { config.data = this.beforeUpdate(config.data)
config.data = this.beforeUpdate(config.data) config.data = objectToSnakeCase(config.data)
config.data = objectToSnakeCase(config.data)
}
break break
case 'put': case 'put':
if (this.useCreateInterceptor()) { if (this.useCreateInterceptor()) {
@ -86,10 +89,7 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
} }
break break
case 'delete': case 'delete':
if (this.useDeleteInterceptor()) { config.data = objectToSnakeCase(config.data)
config.data = this.beforeDelete(config.data)
config.data = objectToSnakeCase(config.data)
}
break break
} }
return config return config
@ -105,20 +105,6 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
return true return true
} }
/**
* Whether or not to use the update interceptor which processes a request payload into json
*/
useUpdateInterceptor(): boolean {
return true
}
/**
* Whether or not to use the delete interceptor which processes a request payload into json
*/
useDeleteInterceptor(): boolean {
return true
}
///////////////// /////////////////
// Helper functions // Helper functions
/////////////// ///////////////
@ -126,10 +112,9 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
/** /**
* Returns an object with all route parameters and their values. * Returns an object with all route parameters and their values.
*/ */
getRouteReplacements(route : string, parameters : Record<string, unknown> = {}) { private getRouteReplacements(route: string, parameters: {[key: string]: unknown} = {}) {
const replace$$1: Record<string, unknown> = {} const replace$$1: Record<string, unknown> = {}
let pattern = this.getRouteParameterPattern() const pattern = new RegExp(ROUTE_PARAMETER_PATTERN, 'g')
pattern = new RegExp(pattern instanceof RegExp ? pattern.source : pattern, 'g')
for (let parameter; (parameter = pattern.exec(route)) !== null;) { for (let parameter; (parameter = pattern.exec(route)) !== null;) {
replace$$1[parameter[0]] = parameters[parameter[1]] replace$$1[parameter[0]] = parameters[parameter[1]]
@ -138,17 +123,10 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
return replace$$1 return replace$$1
} }
/**
* Holds the replacement pattern for url paths, can be overwritten by implementations.
*/
getRouteParameterPattern(): RegExp {
return /{([^}]+)}/
}
/** /**
* Returns a fully-ready-ready-to-make-a-request-to route with replaced parameters. * Returns a fully-ready-ready-to-make-a-request-to route with replaced parameters.
*/ */
getReplacedRoute(path : string, pathparams : Record<string, unknown>) : string { getReplacedRoute<M extends {[key: string]: unknown}>(path : string, pathparams : M) : string {
const replacements = this.getRouteReplacements(path, pathparams) const replacements = this.getRouteReplacements(path, pathparams)
return Object.entries(replacements).reduce( return Object.entries(replacements).reduce(
(result, [parameter, value]) => result.replace(parameter, value as string), (result, [parameter, value]) => result.replace(parameter, value as string),
@ -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. * Sets the loading variable to true, after a timeout of 100ms.
* It has the timeout to prevent the loading indicator from showing for only a blink of an eye in the * The timeout prevents the loading indicator from showing for only a blink of an eye in the
* case the api returns a response in < 100ms. * case the api returns a response in < 100ms.
* But because the timeout is created using setTimeout, it will still trigger even if the request is * But because the timeout is created using setTimeout, it will still trigger even if the request is
* already finished, so we return a method to call in that case. * already finished, so we return a method to call in that case.
@ -184,36 +162,36 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
* The modelFactory returns a model from an object. * The modelFactory returns a model from an object.
* This one here is the default one, usually the service definitions for a model will override this. * This one here is the default one, usually the service definitions for a model will override this.
*/ */
modelFactory(data : Partial<Model> = {}) { modelFactory<DefaultModel extends {[key: string]: unknown} = Partial<Model>>(data: DefaultModel = {}) {
return {...data} as Model return {...data} as DefaultModel
} }
/** /**
* This is the model factory for get requests. * This is the model factory for get requests.
*/ */
modelGetFactory(data : Partial<Model> = {}) { modelGetFactory<GetModel extends {[key: string]: unknown} = Partial<Model>>(data: GetModel = {}) {
return this.modelFactory(data) return this.modelFactory<GetModel>(data)
} }
/** /**
* This is the model factory for get all requests. * This is the model factory for get all requests.
*/ */
modelGetAllFactory(data : Partial<Model>) { modelGetAllFactory<GetAllModel extends {[key: string]: unknown} = Partial<Model>>(data: GetAllModel = {}) {
return this.modelFactory(data) return this.modelFactory<GetAllModel>(data)
} }
/** /**
* This is the model factory for create requests. * This is the model factory for create requests.
*/ */
modelCreateFactory(data : Partial<Model>) { modelCreateFactory<CreateModel extends {[key: string]: unknown} = Partial<Model>>(data: CreateModel = {}) {
return this.modelFactory(data) return this.modelFactory<CreateModel>(data)
} }
/** /**
* This is the model factory for update requests. * This is the model factory for update requests.
*/ */
modelUpdateFactory(data : Partial<Model>) { modelUpdateFactory<UpdateModel extends {[key: string]: unknown} = Partial<Model>>(data: UpdateModel = {}) {
return this.modelFactory(data) return this.modelFactory<UpdateModel>(data)
} }
////////////// //////////////
@ -223,28 +201,21 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
/** /**
* Default preprocessor for get requests * Default preprocessor for get requests
*/ */
beforeGet(model : Model) { beforeGet(model: Model) {
return model return model
} }
/** /**
* Default preprocessor for create requests * Default preprocessor for create requests
*/ */
beforeCreate(model : Model) { beforeCreate(model: Model) {
return model return model
} }
/** /**
* Default preprocessor for update requests * Default preprocessor for update requests
*/ */
beforeUpdate(model : Model) { beforeUpdate(model: Model) {
return model
}
/**
* Default preprocessor for delete requests
*/
beforeDelete(model : Model) {
return model return model
} }
@ -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 model The model to use. The request path is built using the values from the model.
* @param params Optional query parameters * @param params Optional query parameters
*/ */
get(model : Model, params = {}) { get(model: Model, params = {}) {
if (this.paths.get === '') { if (this.paths.get === '') {
throw new Error('This model is not able to get data.') throw new Error('This model is not able to get data.')
} }
@ -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. * This is a more abstract implementation which only does a get request.
* Services which need more flexibility can use this. * Services which need more flexibility can use this.
*/ */
async getM(url : string, model : Model = this.modelGetFactory(), params: Record<string, unknown> = {}) { async getM(url : string, model: Model = this.modelGetFactory(), params: Record<string, unknown> = {}) {
const cancel = this.setLoading() const cancel = this.setLoading()
model = this.beforeGet(model) model = this.beforeGet(model)
const finalUrl = this.getReplacedRoute(url, model) const finalUrl = this.getReplacedRoute(url, model)
try { try {
const response = await this.http.get(finalUrl, {params: prepareParams(params)}) const response = await this.http.get<Model>(finalUrl, {params: prepareParams(params)})
const result = this.modelGetFactory(response.data) const result = this.modelGetFactory(response.data)
result.maxRight = Number(response.headers['x-max-right']) as Right result.maxRight = Number(response.headers['x-max-right']) as Right
return result return result
@ -314,7 +285,7 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
const finalUrl = this.getReplacedRoute(this.paths.getAll, model) const finalUrl = this.getReplacedRoute(this.paths.getAll, model)
try { try {
const response = await this.http.get(finalUrl, {params: prepareParams(params)}) const response = await this.http.get<Model>(finalUrl, {params: prepareParams(params)})
this.resultCount = Number(response.headers['x-pagination-result-count']) this.resultCount = Number(response.headers['x-pagination-result-count'])
this.totalPages = Number(response.headers['x-pagination-total-pages']) this.totalPages = Number(response.headers['x-pagination-total-pages'])
@ -325,7 +296,7 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
if (Array.isArray(response.data)) { if (Array.isArray(response.data)) {
return response.data.map(entry => this.modelGetAllFactory(entry)) return response.data.map(entry => this.modelGetAllFactory(entry))
} }
return this.modelGetAllFactory(response.data) return [this.modelGetAllFactory(response.data)]
} finally { } finally {
cancel() cancel()
} }
@ -333,9 +304,8 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
/** /**
* Performs a put request to the url specified before * Performs a put request to the url specified before
* @returns {Promise<any | never>}
*/ */
async create(model : Model) { async create<M = Model>(model: M) {
if (this.paths.create === '') { if (this.paths.create === '') {
throw new Error('This model is not able to create data.') throw new Error('This model is not able to create data.')
} }
@ -344,7 +314,7 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
const finalUrl = this.getReplacedRoute(this.paths.create, model) const finalUrl = this.getReplacedRoute(this.paths.create, model)
try { try {
const response = await this.http.put(finalUrl, model) const response = await this.http.put<M>(finalUrl, model)
const result = this.modelCreateFactory(response.data) const result = this.modelCreateFactory(response.data)
if (typeof model.maxRight !== 'undefined') { if (typeof model.maxRight !== 'undefined') {
result.maxRight = model.maxRight result.maxRight = model.maxRight
@ -359,11 +329,11 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
* An abstract implementation to send post requests. * An abstract implementation to send post requests.
* Services can use this to implement functions to do post requests other than using the update method. * Services can use this to implement functions to do post requests other than using the update method.
*/ */
async post(url : string, model : Model) { async post<PostModel extends {[key: string]: unknown} = Model>(url : string, model: PostModel) {
const cancel = this.setLoading() const cancel = this.setLoading()
try { try {
const response = await this.http.post(url, model) const response = await this.http.post<PostModel>(url, model)
const result = this.modelUpdateFactory(response.data) const result = this.modelUpdateFactory(response.data)
if (typeof model.maxRight !== 'undefined') { if (typeof model.maxRight !== 'undefined') {
result.maxRight = model.maxRight result.maxRight = model.maxRight
@ -377,7 +347,7 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
/** /**
* Performs a post request to the update url * Performs a post request to the update url
*/ */
update(model : Model) { update<M = Model>(model: M) {
if (this.paths.update === '') { if (this.paths.update === '') {
throw new Error('This model is not able to update data.') throw new Error('This model is not able to update data.')
} }

View File

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

View File

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

View File

@ -1,23 +1,12 @@
import {computed, ref, shallowReactive, unref, watch} from 'vue'
import type {MaybeRef} from '@vueuse/core'
import {useI18n} from 'vue-i18n'
import {useRouter} from 'vue-router'
import type {IList} from '@/modelTypes/IList' 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 AbstractService from '@/services/abstractService'
import TaskService from '@/services/task' import TaskService from '@/services/task'
import ListDuplicateService from '@/services/listDuplicateService'
import ListModel from '@/models/list' import ListModel from '@/models/list'
import ListDuplicateModel from '@/models/listDuplicateModel'
import {colorFromHex} from '@/helpers/color/colorFromHex' import {colorFromHex} from '@/helpers/color/colorFromHex'
import {success} from '@/message' import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
export default class ListService extends AbstractService<IList> { export default class ListService extends AbstractService<IList> {
constructor() { constructor() {
@ -54,17 +43,35 @@ export default class ListService extends AbstractService<IList> {
return list return list
} }
async background(list: Pick<IList, 'id' | 'backgroundInformation'>) { // FIXME: move out of service
if (list.backgroundInformation === null) { async getBlurHash(blurHashString: IList['backgroundBlurHash']) {
return '' if (!blurHashString) {
return
} }
const blurHashBlob = await getBlobFromBlurHash(blurHashString)
if (!blurHashBlob) {
return
}
return URL.createObjectURL(blurHashBlob)
}
const response = await this.http({ async loadBackground(list: Pick<IList, 'id' | 'backgroundInformation'>) {
url: `/lists/${list.id}/background`, if (list === null || !list.backgroundInformation) {
method: 'GET', return
responseType: 'blob', }
})
return window.URL.createObjectURL(new Blob([response.data])) 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'>) { async removeBackground(list: Pick<IList, 'id'>) {
@ -77,59 +84,4 @@ export default class ListService extends AbstractService<IList> {
cancel() 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

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

View File

@ -1,8 +1,6 @@
import {readonly, ref} from 'vue' import {readonly, ref} from 'vue'
import {defineStore, acceptHMRUpdate} from 'pinia' import {defineStore, acceptHMRUpdate} from 'pinia'
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
import ListModel from '@/models/list' import ListModel from '@/models/list'
import ListService from '@/services/list' import ListService from '@/services/list'
import {checkAndSetApiUrl} from '@/helpers/checkAndSetApiUrl' import {checkAndSetApiUrl} from '@/helpers/checkAndSetApiUrl'
@ -97,20 +95,30 @@ export const useBaseStore = defineStore('base', () => {
// The forceUpdate parameter is used only when updating a list background directly because in that case // 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. // the current list stays the same, but we want to show the new background right away.
if (list.id !== currentList.value.id || forceUpdate) { if (
if (list.backgroundInformation) { (list.id !== currentList.value.id || forceUpdate) &&
try { list.backgroundInformation
const blurHash = await getBlobFromBlurHash(list.backgroundBlurHash) ) {
try {
const listService = new ListService()
const blurHashPromise = listService.getBlurHash(list.backgroundBlurHash).then(blurHash => {
if (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) 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 type {MaybeRef} from '@vueuse/core'
import {acceptHMRUpdate, defineStore} from 'pinia' import {acceptHMRUpdate, defineStore} from 'pinia'
import {useI18n} from 'vue-i18n' import {useI18n} from 'vue-i18n'
import {useRouter} from 'vue-router'
import type {IList} from '@/modelTypes/IList' 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 {LIST_ID} from '@/constants/lists'
import {NAMESPACE_ID} from "@/constants/namespaces" import {NAMESPACE_ID} from '@/constants/namespaces'
import {useNamespaceStore} from '@/stores/namespaces' import ListService from '@/services/list'
import {useBaseStore} from '@/stores/base' import ListDuplicateService from '@/services/listDuplicateService'
import BackgroundUnsplashService from '@/services/backgroundUnsplash'
import ListModel from '@/models/list' import ListModel from '@/models/list'
import ListDuplicateModel from '@/models/listDuplicateModel'
import {removeListFromHistory} from '@/modules/listHistory' import {removeListFromHistory} from '@/modules/listHistory'
import ListService from '@/services/list'
import {setModuleLoading} from '@/stores/helper' import {setModuleLoading} from '@/stores/helper'
import {useBaseStore} from './base'
import {useNamespaceStore} from './namespaces'
import {createNewIndexer} from '@/indexes' import {createNewIndexer} from '@/indexes'
import {success} from '@/message' import {success} from '@/message'
import { i18n } from '@/i18n'
import BackgroundUploadService from '@/services/backgroundUpload'
const {add, remove, search, update} = createNewIndexer('lists', ['title', 'description']) const {add, remove, search, update} = createNewIndexer('lists', ['title', 'description'])
@ -25,6 +35,8 @@ export interface ListState {
} }
export const useListStore = defineStore('list', () => { export const useListStore = defineStore('list', () => {
const router = useRouter()
const baseStore = useBaseStore() const baseStore = useBaseStore()
const namespaceStore = useNamespaceStore() const namespaceStore = useNamespaceStore()
@ -55,7 +67,7 @@ export const useListStore = defineStore('list', () => {
?.filter(value => value > 0) ?.filter(value => value > 0)
.map(id => lists.value[id]) .map(id => lists.value[id])
.filter(list => list.isArchived === includeArchived) .filter(list => list.isArchived === includeArchived)
|| [] || [] as IList[]
} }
}) })
@ -79,11 +91,6 @@ export const useListStore = defineStore('list', () => {
}) })
} }
function removeListById(list: IList) {
remove(list)
delete lists.value[list.id]
}
function toggleListFavorite(list: IList) { function toggleListFavorite(list: IList) {
// The favorites pseudo list is always favorite // The favorites pseudo list is always favorite
// Archived lists cannot be marked favorite // Archived lists cannot be marked favorite
@ -96,6 +103,20 @@ export const useListStore = defineStore('list', () => {
}) })
} }
async function loadList(listId: List['id']) {
const cancel = setModuleLoading(this, setIsLoading)
const listService = new ListService()
try {
const list = await listService.get(new ListModel({id: listId}))
setList(list)
namespaceStore.setListInNamespaceById(list)
return list
} finally {
cancel()
}
}
async function createList(list: IList) { async function createList(list: IList) {
const cancel = setModuleLoading(setIsLoading) const cancel = setModuleLoading(setIsLoading)
const listService = new ListService() const listService = new ListService()
@ -152,7 +173,8 @@ export const useListStore = defineStore('list', () => {
try { try {
const response = await listService.delete(list) const response = await listService.delete(list)
removeListById(list) remove(list)
delete lists.value[list.id]
namespaceStore.removeListFromNamespaceById(list) namespaceStore.removeListFromNamespaceById(list)
removeListFromHistory({id: list.id}) removeListFromHistory({id: list.id})
return response return response
@ -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}})
}
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
}
const list: IList = await backgroundService.update({
id: backgroundId,
listId,
})
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 { return {
isLoading: readonly(isLoading), isLoading: readonly(isLoading),
lists: readonly(lists), lists: readonly(lists),
@ -171,43 +305,168 @@ export const useListStore = defineStore('list', () => {
setList, setList,
setLists, setLists,
removeListById,
toggleListFavorite, toggleListFavorite,
// crud
loadList,
createList, createList,
updateList, updateList,
deleteList, 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 // support hot reloading
if (import.meta.hot) { if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useListStore, import.meta.hot)) import.meta.hot.accept(acceptHMRUpdate(useListStore, import.meta.hot))
}
export function useListBase(listId?: MaybeRef<IList['id']>) {
const listStore = useListStore()
const listService = shallowReactive(new ListService())
const {loading: isLoading} = toRefs(listService)
const list = ref<IList>(new ListModel())
const currentListId = computed(() => unref(listId))
// load list
watch(
currentListId,
async (listId, oldListId) => {
if (listId === oldListId) {
return
}
if (listId === undefined) {
list.value = new ListModel()
return
}
list.value = listStore.getListById(listId) || list.value
// FIXME: does this also make sense for a newly created list?
list.value = await listService.get(new ListModel({id: listId}))
},
{immediate: true},
)
return {
listStore,
listService,
isLoading,
currentListId,
list,
}
}
export function useList(listId?: MaybeRef<IList['id']>) {
const {
listStore,
listService,
isLoading,
list,
} = useListBase(listId)
const {t} = useI18n({useScope: 'global'})
const router = useRouter()
const baseStore = useBaseStore()
const namespaceStore = useNamespaceStore()
const errors = ref<{ [key: string]: string | boolean }>({})
async function createList(newList?: Partial<IList>) {
if (newList !== undefined) {
list.value = new ListModel(newList)
}
if (list.value.title === '') {
errors.value.createList = true
return
}
errors.value.createList = false
list.value = await listStore.createList(list.value)
await router.push({
name: 'list.index',
params: { listId: list.value.id },
})
success({message: t('list.create.createdSuccess') })
}
async function saveList() {
await listStore.updateList(list.value)
success({message: t('list.edit.success')})
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
}
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 {INamespace} from '@/modelTypes/INamespace'
import type {IList} from '@/modelTypes/IList' import type {IList} from '@/modelTypes/IList'
import {NAMESPACE_ID} from "@/constants/namespaces" import {NAMESPACE_ID} from '@/constants/namespaces'
import {setModuleLoading} from '@/stores/helper' import {setModuleLoading} from '@/stores/helper'
import {useListStore} from '@/stores/lists' import {useListStore} from '@/stores/lists'

View File

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

View File

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

View File

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

View File

@ -27,15 +27,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {computed, ref, watch} from 'vue' import {ref, watch} from 'vue'
import {useTitle} from '@/composables/useTitle' import {useTitle} from '@/composables/useTitle'
import {useI18n} from 'vue-i18n' import {useI18n} from 'vue-i18n'
import {useRoute, useRouter} from 'vue-router'
import {success} from '@/message'
import TaskCollectionService from '@/services/taskCollection' import TaskCollectionService from '@/services/taskCollection'
import Loading from '@/components/misc/loading.vue' import Loading from '@/components/misc/loading.vue'
import {useListStore} from '@/stores/lists' import {useList} from '@/stores/lists'
import type {IList} from '@/modelTypes/IList' import type {IList} from '@/modelTypes/IList'
const {listId} = defineProps<{ const {listId} = defineProps<{
@ -43,8 +41,6 @@ const {listId} = defineProps<{
}>() }>()
const {t} = useI18n({useScope: 'global'}) const {t} = useI18n({useScope: 'global'})
const listStore = useListStore()
const router = useRouter()
const totalTasks = ref<number | null>(null) const totalTasks = ref<number | null>(null)
watch( watch(
@ -62,13 +58,5 @@ watch(
useTitle(() => t('list.delete.title', {list: list?.value?.title})) useTitle(() => t('list.delete.title', {list: list?.value?.title}))
async function deleteList() { const {list, deleteList} = useList(listId)
if (!list.value) {
return
}
await listStore.deleteList(list.value)
success({message: t('list.delete.success')})
router.push({name: 'home'})
}
</script> </script>

View File

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

View File

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

View File

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