@axium/tasks 0.4.14 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/common.d.ts +150 -2
- package/dist/common.js +43 -2
- package/dist/server.js +39 -27
- package/lib/TaskList.svelte +163 -55
- package/locales/en.json +13 -7
- package/package.json +2 -2
- package/routes/tasks/+layout.ts +3 -1
- package/routes/tasks/+page.svelte +12 -7
- package/routes/tasks/+page.ts +1 -1
package/dist/common.d.ts
CHANGED
|
@@ -20,13 +20,21 @@ export declare const Task: z.ZodObject<{
|
|
|
20
20
|
}, z.core.$strip>;
|
|
21
21
|
export interface Task extends z.infer<typeof Task> {
|
|
22
22
|
}
|
|
23
|
+
export type TaskTreeStatus = 'completed' | 'pending' | null;
|
|
24
|
+
export interface TaskTreeNode {
|
|
25
|
+
task: Task;
|
|
26
|
+
subtasks: TaskTreeNode[];
|
|
27
|
+
all: TaskTreeStatus;
|
|
28
|
+
completion: number;
|
|
29
|
+
}
|
|
30
|
+
export declare function buildTaskTree(tasks: Task[]): TaskTreeNode[];
|
|
23
31
|
export declare const TaskListInit: z.ZodObject<{
|
|
24
32
|
name: z.ZodString;
|
|
25
33
|
description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
26
34
|
}, z.core.$strip>;
|
|
27
35
|
export type TaskListInit = z.infer<typeof TaskListInit>;
|
|
28
36
|
export declare const TaskListUpdate: z.ZodObject<{
|
|
29
|
-
|
|
37
|
+
action: z.ZodLiteral<"mark_all_pending" | "delete_completed">;
|
|
30
38
|
}, z.core.$strip>;
|
|
31
39
|
export type TaskListUpdate = z.infer<typeof TaskListUpdate>;
|
|
32
40
|
export declare const TaskList: z.ZodObject<{
|
|
@@ -45,9 +53,39 @@ export declare const TaskList: z.ZodObject<{
|
|
|
45
53
|
id: z.ZodUUID;
|
|
46
54
|
created: z.ZodCoercedDate<unknown>;
|
|
47
55
|
}, z.core.$strip>>>;
|
|
56
|
+
isShared: z.ZodBoolean;
|
|
57
|
+
acl: z.ZodArray<z.ZodObject<{
|
|
58
|
+
itemId: z.ZodUUID;
|
|
59
|
+
userId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
|
|
60
|
+
role: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
61
|
+
tag: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
62
|
+
user: z.ZodOptional<z.ZodNullable<z.ZodObject<{
|
|
63
|
+
id: z.ZodUUID;
|
|
64
|
+
name: z.ZodString;
|
|
65
|
+
email: z.ZodOptional<z.ZodEmail>;
|
|
66
|
+
emailVerified: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>>;
|
|
67
|
+
preferences: z.ZodOptional<z.ZodLazy<z.ZodObject<{
|
|
68
|
+
debug: z.ZodDefault<z.ZodBoolean>;
|
|
69
|
+
}, z.core.$strip>>>;
|
|
70
|
+
roles: z.ZodArray<z.ZodString>;
|
|
71
|
+
tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
72
|
+
registeredAt: z.ZodCoercedDate<unknown>;
|
|
73
|
+
isAdmin: z.ZodOptional<z.ZodBoolean>;
|
|
74
|
+
isSuspended: z.ZodOptional<z.ZodBoolean>;
|
|
75
|
+
}, z.core.$strip>>>;
|
|
76
|
+
createdAt: z.ZodCoercedDate<unknown>;
|
|
77
|
+
}, z.core.$catchall<z.ZodBoolean>>>;
|
|
48
78
|
}, z.core.$strip>;
|
|
49
79
|
export interface TaskList extends z.infer<typeof TaskList> {
|
|
50
80
|
}
|
|
81
|
+
export declare const TasksPreferences: z.ZodObject<{
|
|
82
|
+
show_completed_subtasks: z.ZodDefault<z.ZodLiteral<"inline" | "mirror">>;
|
|
83
|
+
}, z.core.$strip>;
|
|
84
|
+
declare module '@axium/core/apps' {
|
|
85
|
+
interface $AppPreferences {
|
|
86
|
+
tasks: typeof TasksPreferences;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
51
89
|
declare const TasksAPI: {
|
|
52
90
|
readonly 'users/:id/task_lists': {
|
|
53
91
|
readonly GET: z.ZodArray<z.ZodObject<{
|
|
@@ -66,6 +104,28 @@ declare const TasksAPI: {
|
|
|
66
104
|
id: z.ZodUUID;
|
|
67
105
|
created: z.ZodCoercedDate<unknown>;
|
|
68
106
|
}, z.core.$strip>>>>;
|
|
107
|
+
isShared: z.ZodBoolean;
|
|
108
|
+
acl: z.ZodArray<z.ZodObject<{
|
|
109
|
+
itemId: z.ZodUUID;
|
|
110
|
+
userId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
|
|
111
|
+
role: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
112
|
+
tag: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
113
|
+
user: z.ZodOptional<z.ZodNullable<z.ZodObject<{
|
|
114
|
+
id: z.ZodUUID;
|
|
115
|
+
name: z.ZodString;
|
|
116
|
+
email: z.ZodOptional<z.ZodEmail>;
|
|
117
|
+
emailVerified: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>>;
|
|
118
|
+
preferences: z.ZodOptional<z.ZodLazy<z.ZodObject<{
|
|
119
|
+
debug: z.ZodDefault<z.ZodBoolean>;
|
|
120
|
+
}, z.core.$strip>>>;
|
|
121
|
+
roles: z.ZodArray<z.ZodString>;
|
|
122
|
+
tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
123
|
+
registeredAt: z.ZodCoercedDate<unknown>;
|
|
124
|
+
isAdmin: z.ZodOptional<z.ZodBoolean>;
|
|
125
|
+
isSuspended: z.ZodOptional<z.ZodBoolean>;
|
|
126
|
+
}, z.core.$strip>>>;
|
|
127
|
+
createdAt: z.ZodCoercedDate<unknown>;
|
|
128
|
+
}, z.core.$catchall<z.ZodBoolean>>>;
|
|
69
129
|
}, z.core.$strip>>;
|
|
70
130
|
readonly PUT: readonly [z.ZodObject<{
|
|
71
131
|
name: z.ZodString;
|
|
@@ -86,6 +146,28 @@ declare const TasksAPI: {
|
|
|
86
146
|
id: z.ZodUUID;
|
|
87
147
|
created: z.ZodCoercedDate<unknown>;
|
|
88
148
|
}, z.core.$strip>>>;
|
|
149
|
+
isShared: z.ZodBoolean;
|
|
150
|
+
acl: z.ZodArray<z.ZodObject<{
|
|
151
|
+
itemId: z.ZodUUID;
|
|
152
|
+
userId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
|
|
153
|
+
role: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
154
|
+
tag: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
155
|
+
user: z.ZodOptional<z.ZodNullable<z.ZodObject<{
|
|
156
|
+
id: z.ZodUUID;
|
|
157
|
+
name: z.ZodString;
|
|
158
|
+
email: z.ZodOptional<z.ZodEmail>;
|
|
159
|
+
emailVerified: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>>;
|
|
160
|
+
preferences: z.ZodOptional<z.ZodLazy<z.ZodObject<{
|
|
161
|
+
debug: z.ZodDefault<z.ZodBoolean>;
|
|
162
|
+
}, z.core.$strip>>>;
|
|
163
|
+
roles: z.ZodArray<z.ZodString>;
|
|
164
|
+
tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
165
|
+
registeredAt: z.ZodCoercedDate<unknown>;
|
|
166
|
+
isAdmin: z.ZodOptional<z.ZodBoolean>;
|
|
167
|
+
isSuspended: z.ZodOptional<z.ZodBoolean>;
|
|
168
|
+
}, z.core.$strip>>>;
|
|
169
|
+
createdAt: z.ZodCoercedDate<unknown>;
|
|
170
|
+
}, z.core.$catchall<z.ZodBoolean>>>;
|
|
89
171
|
}, z.core.$strip>];
|
|
90
172
|
};
|
|
91
173
|
readonly 'task_lists/:id': {
|
|
@@ -105,6 +187,28 @@ declare const TasksAPI: {
|
|
|
105
187
|
id: z.ZodUUID;
|
|
106
188
|
created: z.ZodCoercedDate<unknown>;
|
|
107
189
|
}, z.core.$strip>>>>;
|
|
190
|
+
isShared: z.ZodBoolean;
|
|
191
|
+
acl: z.ZodArray<z.ZodObject<{
|
|
192
|
+
itemId: z.ZodUUID;
|
|
193
|
+
userId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
|
|
194
|
+
role: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
195
|
+
tag: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
196
|
+
user: z.ZodOptional<z.ZodNullable<z.ZodObject<{
|
|
197
|
+
id: z.ZodUUID;
|
|
198
|
+
name: z.ZodString;
|
|
199
|
+
email: z.ZodOptional<z.ZodEmail>;
|
|
200
|
+
emailVerified: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>>;
|
|
201
|
+
preferences: z.ZodOptional<z.ZodLazy<z.ZodObject<{
|
|
202
|
+
debug: z.ZodDefault<z.ZodBoolean>;
|
|
203
|
+
}, z.core.$strip>>>;
|
|
204
|
+
roles: z.ZodArray<z.ZodString>;
|
|
205
|
+
tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
206
|
+
registeredAt: z.ZodCoercedDate<unknown>;
|
|
207
|
+
isAdmin: z.ZodOptional<z.ZodBoolean>;
|
|
208
|
+
isSuspended: z.ZodOptional<z.ZodBoolean>;
|
|
209
|
+
}, z.core.$strip>>>;
|
|
210
|
+
createdAt: z.ZodCoercedDate<unknown>;
|
|
211
|
+
}, z.core.$catchall<z.ZodBoolean>>>;
|
|
108
212
|
}, z.core.$strip>;
|
|
109
213
|
readonly PATCH: readonly [z.ZodObject<{
|
|
110
214
|
name: z.ZodString;
|
|
@@ -125,9 +229,31 @@ declare const TasksAPI: {
|
|
|
125
229
|
id: z.ZodUUID;
|
|
126
230
|
created: z.ZodCoercedDate<unknown>;
|
|
127
231
|
}, z.core.$strip>>>;
|
|
232
|
+
isShared: z.ZodBoolean;
|
|
233
|
+
acl: z.ZodArray<z.ZodObject<{
|
|
234
|
+
itemId: z.ZodUUID;
|
|
235
|
+
userId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
|
|
236
|
+
role: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
237
|
+
tag: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
238
|
+
user: z.ZodOptional<z.ZodNullable<z.ZodObject<{
|
|
239
|
+
id: z.ZodUUID;
|
|
240
|
+
name: z.ZodString;
|
|
241
|
+
email: z.ZodOptional<z.ZodEmail>;
|
|
242
|
+
emailVerified: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>>;
|
|
243
|
+
preferences: z.ZodOptional<z.ZodLazy<z.ZodObject<{
|
|
244
|
+
debug: z.ZodDefault<z.ZodBoolean>;
|
|
245
|
+
}, z.core.$strip>>>;
|
|
246
|
+
roles: z.ZodArray<z.ZodString>;
|
|
247
|
+
tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
248
|
+
registeredAt: z.ZodCoercedDate<unknown>;
|
|
249
|
+
isAdmin: z.ZodOptional<z.ZodBoolean>;
|
|
250
|
+
isSuspended: z.ZodOptional<z.ZodBoolean>;
|
|
251
|
+
}, z.core.$strip>>>;
|
|
252
|
+
createdAt: z.ZodCoercedDate<unknown>;
|
|
253
|
+
}, z.core.$catchall<z.ZodBoolean>>>;
|
|
128
254
|
}, z.core.$strip>];
|
|
129
255
|
readonly POST: readonly [z.ZodObject<{
|
|
130
|
-
|
|
256
|
+
action: z.ZodLiteral<"mark_all_pending" | "delete_completed">;
|
|
131
257
|
}, z.core.$strip>, z.ZodUnknown];
|
|
132
258
|
readonly PUT: readonly [z.ZodObject<{
|
|
133
259
|
summary: z.ZodOptional<z.ZodString>;
|
|
@@ -161,6 +287,28 @@ declare const TasksAPI: {
|
|
|
161
287
|
id: z.ZodUUID;
|
|
162
288
|
created: z.ZodCoercedDate<unknown>;
|
|
163
289
|
}, z.core.$strip>>>;
|
|
290
|
+
isShared: z.ZodBoolean;
|
|
291
|
+
acl: z.ZodArray<z.ZodObject<{
|
|
292
|
+
itemId: z.ZodUUID;
|
|
293
|
+
userId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
|
|
294
|
+
role: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
295
|
+
tag: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
296
|
+
user: z.ZodOptional<z.ZodNullable<z.ZodObject<{
|
|
297
|
+
id: z.ZodUUID;
|
|
298
|
+
name: z.ZodString;
|
|
299
|
+
email: z.ZodOptional<z.ZodEmail>;
|
|
300
|
+
emailVerified: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>>;
|
|
301
|
+
preferences: z.ZodOptional<z.ZodLazy<z.ZodObject<{
|
|
302
|
+
debug: z.ZodDefault<z.ZodBoolean>;
|
|
303
|
+
}, z.core.$strip>>>;
|
|
304
|
+
roles: z.ZodArray<z.ZodString>;
|
|
305
|
+
tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
306
|
+
registeredAt: z.ZodCoercedDate<unknown>;
|
|
307
|
+
isAdmin: z.ZodOptional<z.ZodBoolean>;
|
|
308
|
+
isSuspended: z.ZodOptional<z.ZodBoolean>;
|
|
309
|
+
}, z.core.$strip>>>;
|
|
310
|
+
createdAt: z.ZodCoercedDate<unknown>;
|
|
311
|
+
}, z.core.$catchall<z.ZodBoolean>>>;
|
|
164
312
|
}, z.core.$strip>;
|
|
165
313
|
};
|
|
166
314
|
readonly 'tasks/:id': {
|
package/dist/common.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { $API } from '@axium/core';
|
|
1
|
+
import { $API, AccessControl, appPreferences } from '@axium/core';
|
|
2
|
+
import { zKeys } from '@axium/core/locales';
|
|
2
3
|
import * as z from 'zod';
|
|
3
4
|
export const TaskInit = z.object({
|
|
4
5
|
summary: z.string().max(100).optional(),
|
|
@@ -12,19 +13,59 @@ export const Task = TaskInit.extend({
|
|
|
12
13
|
id: z.uuid(),
|
|
13
14
|
created: z.coerce.date(),
|
|
14
15
|
});
|
|
16
|
+
function computeStats(node, force = false) {
|
|
17
|
+
if (node.all && node.completion !== undefined && !force)
|
|
18
|
+
return;
|
|
19
|
+
let all = node.task.completed ? 'completed' : 'pending', completion = node.subtasks.length ? 0 : node.task.completed ? 1 : 0;
|
|
20
|
+
for (const sub of node.subtasks) {
|
|
21
|
+
computeStats(sub, force);
|
|
22
|
+
if (!sub.all || sub.all !== all)
|
|
23
|
+
all = null;
|
|
24
|
+
completion += sub.completion / node.subtasks.length;
|
|
25
|
+
}
|
|
26
|
+
node.all = all;
|
|
27
|
+
node.completion = completion;
|
|
28
|
+
}
|
|
29
|
+
export function buildTaskTree(tasks) {
|
|
30
|
+
const nodes = new Map();
|
|
31
|
+
const roots = [];
|
|
32
|
+
for (const task of tasks)
|
|
33
|
+
nodes.set(task.id, { task, subtasks: [], all: null, completion: 0 });
|
|
34
|
+
for (const task of tasks) {
|
|
35
|
+
const node = nodes.get(task.id);
|
|
36
|
+
if (task.parentId)
|
|
37
|
+
nodes.get(task.parentId)?.subtasks.push(node);
|
|
38
|
+
else
|
|
39
|
+
roots.push(node);
|
|
40
|
+
}
|
|
41
|
+
for (const node of roots)
|
|
42
|
+
computeStats(node);
|
|
43
|
+
return roots;
|
|
44
|
+
}
|
|
15
45
|
export const TaskListInit = z.object({
|
|
16
46
|
name: z.string().min(1).max(50),
|
|
17
47
|
description: z.string().max(500).nullish(),
|
|
18
48
|
});
|
|
19
49
|
export const TaskListUpdate = z.object({
|
|
20
|
-
|
|
50
|
+
action: z.literal(['mark_all_pending', 'delete_completed']),
|
|
21
51
|
});
|
|
22
52
|
export const TaskList = TaskListInit.extend({
|
|
23
53
|
id: z.uuid(),
|
|
24
54
|
userId: z.uuid(),
|
|
25
55
|
created: z.coerce.date(),
|
|
26
56
|
tasks: Task.array().optional(),
|
|
57
|
+
isShared: z.boolean(),
|
|
58
|
+
acl: AccessControl.array(),
|
|
27
59
|
});
|
|
60
|
+
export const TasksPreferences = z
|
|
61
|
+
.object({
|
|
62
|
+
show_completed_subtasks: z
|
|
63
|
+
.literal(['inline', 'mirror'])
|
|
64
|
+
.default('inline')
|
|
65
|
+
.register(zKeys, { prefix: 'tasks.show_completed_subtasks' }),
|
|
66
|
+
})
|
|
67
|
+
.register(zKeys, { prefix: 'tasks.preferences' });
|
|
68
|
+
appPreferences.set('tasks', TasksPreferences);
|
|
28
69
|
const TasksAPI = {
|
|
29
70
|
'users/:id/task_lists': {
|
|
30
71
|
GET: TaskList.required({ tasks: true }).array(),
|
package/dist/server.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as acl from '@axium/server/acl';
|
|
1
2
|
import { authRequestForItem, checkAuthForUser } from '@axium/server/auth';
|
|
2
3
|
import { database } from '@axium/server/database';
|
|
3
4
|
import { parseBody, withError } from '@axium/server/requests';
|
|
@@ -9,14 +10,17 @@ addRoute({
|
|
|
9
10
|
path: '/api/users/:id/task_lists',
|
|
10
11
|
params: { id: z.uuid() },
|
|
11
12
|
async GET(request, { id: userId }) {
|
|
12
|
-
await checkAuthForUser(request, userId);
|
|
13
|
+
const { user } = await checkAuthForUser(request, userId);
|
|
14
|
+
const shared = acl.existsIn('task_lists', user, { alias: 'list' });
|
|
13
15
|
const lists = await database
|
|
14
|
-
.selectFrom('task_lists')
|
|
15
|
-
.selectAll()
|
|
16
|
-
.select(eb => jsonArrayFrom(eb.selectFrom('tasks').selectAll().whereRef('tasks.listId', '=', '
|
|
16
|
+
.selectFrom('task_lists as list')
|
|
17
|
+
.selectAll('list')
|
|
18
|
+
.select(eb => jsonArrayFrom(eb.selectFrom('tasks').selectAll().whereRef('tasks.listId', '=', 'list.id'))
|
|
17
19
|
.$castTo()
|
|
18
20
|
.as('tasks'))
|
|
19
|
-
.
|
|
21
|
+
.select(acl.from('task_lists', { alias: 'list' }))
|
|
22
|
+
.select(eb => shared(eb).$castTo().as('isShared'))
|
|
23
|
+
.where(eb => eb.or([eb('userId', '=', userId), shared(eb)]))
|
|
20
24
|
.execute()
|
|
21
25
|
.catch(withError('Could not get task lists'));
|
|
22
26
|
return lists;
|
|
@@ -24,71 +28,79 @@ addRoute({
|
|
|
24
28
|
async PUT(request, { id: userId }) {
|
|
25
29
|
const init = await parseBody(request, TaskListInit);
|
|
26
30
|
await checkAuthForUser(request, userId);
|
|
27
|
-
return await database
|
|
31
|
+
return Object.assign(await database
|
|
28
32
|
.insertInto('task_lists')
|
|
29
33
|
.values({ ...init, userId })
|
|
30
34
|
.returningAll()
|
|
31
35
|
.executeTakeFirstOrThrow()
|
|
32
|
-
.catch(withError('Could not create task list'));
|
|
36
|
+
.catch(withError('Could not create task list')), { isShared: false, acl: [] });
|
|
33
37
|
},
|
|
34
38
|
});
|
|
35
39
|
addRoute({
|
|
36
40
|
path: '/api/task_lists/:id',
|
|
37
41
|
params: { id: z.uuid() },
|
|
38
42
|
async GET(request, { id }) {
|
|
39
|
-
const { item } = await authRequestForItem(request, 'task_lists', id, { read: true });
|
|
43
|
+
const { item, fromACL } = await authRequestForItem(request, 'task_lists', id, { read: true });
|
|
40
44
|
const tasks = await database
|
|
41
45
|
.selectFrom('tasks')
|
|
42
46
|
.selectAll()
|
|
43
47
|
.where('listId', '=', id)
|
|
44
48
|
.execute()
|
|
45
49
|
.catch(withError('Could not get tasks'));
|
|
46
|
-
return Object.assign(item, { tasks });
|
|
50
|
+
return Object.assign(item, { tasks, isShared: fromACL });
|
|
47
51
|
},
|
|
48
52
|
async PUT(request, { id: listId }) {
|
|
49
53
|
const init = await parseBody(request, TaskInit.omit({ listId: true }));
|
|
50
|
-
await authRequestForItem(request, 'task_lists', listId, { edit: true });
|
|
51
|
-
return await database
|
|
54
|
+
const { fromACL } = await authRequestForItem(request, 'task_lists', listId, { edit: true });
|
|
55
|
+
return Object.assign(await database
|
|
52
56
|
.insertInto('tasks')
|
|
53
57
|
.values({ summary: '', ...init, listId })
|
|
54
58
|
.returningAll()
|
|
55
59
|
.executeTakeFirstOrThrow()
|
|
56
|
-
.catch(withError('Could not update task list'));
|
|
60
|
+
.catch(withError('Could not update task list')), { isShared: fromACL });
|
|
57
61
|
},
|
|
58
62
|
async PATCH(request, { id }) {
|
|
59
|
-
await authRequestForItem(request, 'task_lists', id, { edit: true });
|
|
60
63
|
const init = await parseBody(request, TaskListInit);
|
|
61
|
-
|
|
64
|
+
const { item, fromACL } = await authRequestForItem(request, 'task_lists', id, { edit: true });
|
|
65
|
+
return Object.assign(await database
|
|
62
66
|
.updateTable('task_lists')
|
|
63
67
|
.set(init)
|
|
64
68
|
.where('id', '=', id)
|
|
65
69
|
.returningAll()
|
|
66
70
|
.executeTakeFirstOrThrow()
|
|
67
|
-
.catch(withError('Could not update task list'));
|
|
71
|
+
.catch(withError('Could not update task list')), { isShared: fromACL, acl: item.acl });
|
|
68
72
|
},
|
|
69
73
|
async POST(request, { id }) {
|
|
70
74
|
const body = await parseBody(request, TaskListUpdate);
|
|
71
75
|
await authRequestForItem(request, 'task_lists', id, { edit: true });
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
76
|
+
switch (body.action) {
|
|
77
|
+
case 'mark_all_pending':
|
|
78
|
+
await database
|
|
79
|
+
.updateTable('tasks')
|
|
80
|
+
.set('completed', false)
|
|
81
|
+
.where('listId', '=', id)
|
|
82
|
+
.executeTakeFirstOrThrow()
|
|
83
|
+
.catch(withError('Could not update task list'));
|
|
84
|
+
break;
|
|
85
|
+
case 'delete_completed':
|
|
86
|
+
await database
|
|
87
|
+
.deleteFrom('tasks')
|
|
88
|
+
.where('listId', '=', id)
|
|
89
|
+
.where('completed', '=', true)
|
|
90
|
+
.executeTakeFirstOrThrow()
|
|
91
|
+
.catch(withError('Could not update task list'));
|
|
92
|
+
break;
|
|
81
93
|
}
|
|
82
94
|
return {};
|
|
83
95
|
},
|
|
84
96
|
async DELETE(request, { id }) {
|
|
85
|
-
await authRequestForItem(request, 'task_lists', id, { manage: true });
|
|
86
|
-
return await database
|
|
97
|
+
const { item, fromACL } = await authRequestForItem(request, 'task_lists', id, { manage: true });
|
|
98
|
+
return Object.assign(await database
|
|
87
99
|
.deleteFrom('task_lists')
|
|
88
100
|
.where('id', '=', id)
|
|
89
101
|
.returningAll()
|
|
90
102
|
.executeTakeFirstOrThrow()
|
|
91
|
-
.catch(withError('Could not delete task list'));
|
|
103
|
+
.catch(withError('Could not delete task list')), { isShared: fromACL, acl: item.acl });
|
|
92
104
|
},
|
|
93
105
|
});
|
|
94
106
|
addRoute({
|
package/lib/TaskList.svelte
CHANGED
|
@@ -1,22 +1,33 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { goto } from '$app/navigation';
|
|
3
3
|
import { page } from '$app/state';
|
|
4
|
-
import {
|
|
5
|
-
import type { UserPublic } from '@axium/core';
|
|
4
|
+
import { fetchAPI, getAppPreferences, text } from '@axium/client';
|
|
6
5
|
import { AccessControlDialog, Icon, Popover } from '@axium/client/components';
|
|
7
|
-
import {
|
|
8
|
-
import
|
|
6
|
+
import { copy } from '@axium/client/gui';
|
|
7
|
+
import { toastStatus } from '@axium/client/toast';
|
|
8
|
+
import { buildTaskTree, type Task, type TaskList, type TaskTreeNode } from '@axium/tasks/common';
|
|
9
9
|
import type { WithRequired } from 'utilium';
|
|
10
10
|
import { download } from 'utilium/dom';
|
|
11
|
-
import { toastStatus } from '@axium/client/toast';
|
|
12
11
|
|
|
13
12
|
let {
|
|
14
13
|
list = $bindable(),
|
|
15
14
|
lists = $bindable(),
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
}: {
|
|
16
|
+
list: WithRequired<TaskList, 'tasks'>;
|
|
17
|
+
lists?: WithRequired<TaskList, 'tasks'>[];
|
|
18
|
+
} = $props();
|
|
19
|
+
|
|
20
|
+
const { user } = page.data.session;
|
|
21
|
+
|
|
22
|
+
let tasks = $state(list.tasks),
|
|
23
|
+
open = $state(false);
|
|
24
|
+
|
|
25
|
+
const preferences = await getAppPreferences(user.id, 'tasks');
|
|
26
|
+
const showCompletedInline = preferences.show_completed_subtasks == 'inline';
|
|
18
27
|
|
|
19
|
-
|
|
28
|
+
const tree = $derived(buildTaskTree(tasks));
|
|
29
|
+
|
|
30
|
+
const completed: TaskTreeNode[] = $derived(tree.filter(node => node.all == 'completed'));
|
|
20
31
|
|
|
21
32
|
function exportTask(task: Task): string {
|
|
22
33
|
const children = tasks
|
|
@@ -31,22 +42,42 @@
|
|
|
31
42
|
let acl = $state<HTMLDialogElement>();
|
|
32
43
|
</script>
|
|
33
44
|
|
|
34
|
-
{#snippet
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
45
|
+
{#snippet task_checkbox(node: TaskTreeNode, isMirror: boolean = false)}
|
|
46
|
+
{@const task = node.task}
|
|
47
|
+
{@const directCompletion = node.subtasks.length ? node.subtasks.filter(s => s.task.completed).length / node.subtasks.length : 0}
|
|
48
|
+
|
|
49
|
+
<span
|
|
50
|
+
class="task-checkbox"
|
|
51
|
+
class:has-progress={node.subtasks.length && node.completion}
|
|
52
|
+
class:mirror-marker={isMirror}
|
|
53
|
+
style:--completion="{node.completion * 100}%"
|
|
54
|
+
style:--direct-completion="{directCompletion * 100}%"
|
|
55
|
+
>
|
|
56
|
+
{#if isMirror}
|
|
57
|
+
<Icon i="regular/circle{task.completed ? '-check' : ''}" --size="1.25em" />
|
|
58
|
+
{:else}
|
|
59
|
+
<label for="task-completed#{task.id}" style:cursor="pointer" style:display="contents">
|
|
60
|
+
<Icon i="regular/circle{task.completed ? '-check' : ''}" --size="1.25em" />
|
|
61
|
+
</label>
|
|
62
|
+
<input
|
|
63
|
+
type="checkbox"
|
|
64
|
+
name="completed"
|
|
65
|
+
bind:checked={task.completed}
|
|
66
|
+
id="task-completed#{task.id}"
|
|
67
|
+
style:display="none"
|
|
68
|
+
oninput={e => {
|
|
69
|
+
task.completed = e.currentTarget.checked;
|
|
70
|
+
fetchAPI('PATCH', 'tasks/:id', { completed: task.completed }, task.id);
|
|
71
|
+
}}
|
|
72
|
+
/>
|
|
73
|
+
{/if}
|
|
74
|
+
</span>
|
|
75
|
+
{/snippet}
|
|
76
|
+
|
|
77
|
+
{#snippet task_tree(node: TaskTreeNode, depth: number = 0, isCompletedMirror: boolean = false)}
|
|
78
|
+
{@const task = node.task}
|
|
79
|
+
<div class="task" style:margin-left="{depth * 1.75}em">
|
|
80
|
+
{@render task_checkbox(node, !showCompletedInline && isCompletedMirror != task.completed)}
|
|
50
81
|
<input
|
|
51
82
|
type="text"
|
|
52
83
|
name="summary"
|
|
@@ -58,6 +89,15 @@
|
|
|
58
89
|
}}
|
|
59
90
|
/>
|
|
60
91
|
<Popover showToggle="hover">
|
|
92
|
+
<div
|
|
93
|
+
class="menu-item"
|
|
94
|
+
onclick={() => {
|
|
95
|
+
fetchAPI('PUT', 'task_lists/:id', { summary: '', parentId: task.id }, list.id).then(t => tasks.push(t));
|
|
96
|
+
}}
|
|
97
|
+
>
|
|
98
|
+
<Icon i="arrow-turn-down-right" />
|
|
99
|
+
<span>{text('tasks.add_subtask')}</span>
|
|
100
|
+
</div>
|
|
61
101
|
<div
|
|
62
102
|
class="menu-item"
|
|
63
103
|
onclick={() =>
|
|
@@ -71,7 +111,7 @@
|
|
|
71
111
|
<Icon i="trash" />
|
|
72
112
|
<span>{text('generic.delete')}</span>
|
|
73
113
|
</div>
|
|
74
|
-
{#if
|
|
114
|
+
{#if user.preferences.debug}
|
|
75
115
|
<div class="menu-item" onclick={() => copy('text/plain', task.id)}>
|
|
76
116
|
<Icon i="hashtag" --size="14px" />
|
|
77
117
|
<span>{text('tasks.copy_id')}</span>
|
|
@@ -79,10 +119,29 @@
|
|
|
79
119
|
{/if}
|
|
80
120
|
</Popover>
|
|
81
121
|
</div>
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
122
|
+
{#each node.subtasks as sub (sub.task.id)}
|
|
123
|
+
{#if sub.all != (isCompletedMirror ? 'pending' : 'completed')}
|
|
124
|
+
{@render task_tree(sub, depth + 1, isCompletedMirror)}
|
|
125
|
+
{/if}
|
|
85
126
|
{/each}
|
|
127
|
+
{@const completedSubs = node.subtasks.filter(sub => sub.all == 'completed')}
|
|
128
|
+
{#if showCompletedInline && completedSubs.length}
|
|
129
|
+
{#snippet inside()}
|
|
130
|
+
{#each completedSubs as sub (sub.task.id)}
|
|
131
|
+
{@render task_tree(sub, depth + 1, isCompletedMirror)}
|
|
132
|
+
{/each}
|
|
133
|
+
{/snippet}
|
|
134
|
+
{#if node.all == 'completed'}
|
|
135
|
+
{@render inside()}
|
|
136
|
+
{:else}
|
|
137
|
+
<details>
|
|
138
|
+
<summary style:margin-left="{depth * 1.75 + 1}em" class="completed-subtasks subtle"
|
|
139
|
+
>{text('tasks.completed_heading', { count: completedSubs.length })}</summary
|
|
140
|
+
>
|
|
141
|
+
{@render inside()}
|
|
142
|
+
</details>
|
|
143
|
+
{/if}
|
|
144
|
+
{/if}
|
|
86
145
|
{/snippet}
|
|
87
146
|
|
|
88
147
|
<div class="task-list">
|
|
@@ -135,31 +194,32 @@
|
|
|
135
194
|
<Icon i="user-group" />
|
|
136
195
|
<span>{text('tasks.share_list')}</span>
|
|
137
196
|
</div>
|
|
138
|
-
{#if tasks.some(t =>
|
|
197
|
+
{#if tasks.some(t => t.completed)}
|
|
139
198
|
<div
|
|
140
199
|
class="menu-item"
|
|
141
200
|
onclick={() => {
|
|
142
|
-
for (const task of tasks) task.completed =
|
|
201
|
+
for (const task of tasks) task.completed = false;
|
|
143
202
|
toastStatus(
|
|
144
|
-
fetchAPI('POST', 'task_lists/:id', {
|
|
145
|
-
text('tasks.
|
|
203
|
+
fetchAPI('POST', 'task_lists/:id', { action: 'mark_all_pending' }, list.id),
|
|
204
|
+
text('tasks.toast_all_pending')
|
|
146
205
|
);
|
|
147
206
|
}}
|
|
148
207
|
>
|
|
149
|
-
<Icon i="regular/circle
|
|
150
|
-
<span>{text('tasks.
|
|
208
|
+
<Icon i="regular/circle" />
|
|
209
|
+
<span>{text('tasks.mark_all_pending')}</span>
|
|
151
210
|
</div>
|
|
152
|
-
{/if}
|
|
153
|
-
{#if tasks.some(t => t.completed)}
|
|
154
211
|
<div
|
|
155
212
|
class="menu-item"
|
|
156
213
|
onclick={() => {
|
|
157
|
-
|
|
158
|
-
toastStatus(
|
|
214
|
+
tasks = tasks.filter(task => !task.completed);
|
|
215
|
+
toastStatus(
|
|
216
|
+
fetchAPI('POST', 'task_lists/:id', { action: 'delete_completed' }, list.id),
|
|
217
|
+
text('tasks.toast_delete_completed')
|
|
218
|
+
);
|
|
159
219
|
}}
|
|
160
220
|
>
|
|
161
|
-
<Icon i="regular/
|
|
162
|
-
<span>{text('tasks.
|
|
221
|
+
<Icon i="regular/trash-can-check" />
|
|
222
|
+
<span>{text('tasks.delete_completed')}</span>
|
|
163
223
|
</div>
|
|
164
224
|
{/if}
|
|
165
225
|
<div class="menu-item" onclick={() => copy('text/plain', `${location.origin}/tasks/${list.id}`)}>
|
|
@@ -167,12 +227,12 @@
|
|
|
167
227
|
<span>{text('tasks.copy_link')}</span>
|
|
168
228
|
</div>
|
|
169
229
|
{#if lists}
|
|
170
|
-
<div class="menu-item" onclick={() => open(`/tasks/${list.id}`)}>
|
|
230
|
+
<div class="menu-item" onclick={() => window.open(`/tasks/${list.id}`)}>
|
|
171
231
|
<Icon i="arrow-up-right-from-square" />
|
|
172
232
|
<span>{text('tasks.open_new_tab')}</span>
|
|
173
233
|
</div>
|
|
174
234
|
{/if}
|
|
175
|
-
{#if
|
|
235
|
+
{#if user.preferences.debug}
|
|
176
236
|
<div class="menu-item" onclick={() => copy('text/plain', list.id)}>
|
|
177
237
|
<Icon i="hashtag" --size="14px" />
|
|
178
238
|
<span>{text('tasks.copy_id')}</span>
|
|
@@ -187,18 +247,31 @@
|
|
|
187
247
|
<span>{text('tasks.new_task')}</span>
|
|
188
248
|
</button>
|
|
189
249
|
</div>
|
|
190
|
-
|
|
191
|
-
{#each
|
|
192
|
-
{@render task_tree(
|
|
250
|
+
|
|
251
|
+
{#each tree.filter(node => node.all != 'completed') as node (node.task.id)}
|
|
252
|
+
{@render task_tree(node)}
|
|
193
253
|
{:else}
|
|
194
254
|
<i class="subtle">{text('tasks.pending_empty')}</i>
|
|
195
255
|
{/each}
|
|
196
|
-
|
|
197
|
-
{#
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
256
|
+
|
|
257
|
+
{#if completed.length || (!showCompletedInline && tasks.some(t => t.completed))}
|
|
258
|
+
<details bind:open>
|
|
259
|
+
<summary
|
|
260
|
+
>{text('tasks.completed_heading', {
|
|
261
|
+
count: (showCompletedInline ? completed : tasks.filter(t => t.completed)).length,
|
|
262
|
+
})}</summary
|
|
263
|
+
>
|
|
264
|
+
{#if showCompletedInline}
|
|
265
|
+
{#each completed as node (node.task.id)}
|
|
266
|
+
{@render task_tree(node)}
|
|
267
|
+
{/each}
|
|
268
|
+
{:else}
|
|
269
|
+
{#each tree.filter(node => node.all != 'pending') as node (node.task.id)}
|
|
270
|
+
{@render task_tree(node, 0, true)}
|
|
271
|
+
{/each}
|
|
272
|
+
{/if}
|
|
273
|
+
</details>
|
|
274
|
+
{/if}
|
|
202
275
|
</div>
|
|
203
276
|
|
|
204
277
|
<style>
|
|
@@ -209,12 +282,39 @@
|
|
|
209
282
|
|
|
210
283
|
.task {
|
|
211
284
|
display: grid;
|
|
212
|
-
grid-template-columns:
|
|
285
|
+
grid-template-columns: 1.25em 1fr 2em;
|
|
213
286
|
align-items: center;
|
|
214
|
-
gap:
|
|
287
|
+
gap: 0.5em;
|
|
288
|
+
|
|
289
|
+
.task-checkbox {
|
|
290
|
+
display: flex;
|
|
291
|
+
align-items: center;
|
|
292
|
+
justify-content: center;
|
|
293
|
+
position: relative;
|
|
294
|
+
border-radius: 50%;
|
|
295
|
+
|
|
296
|
+
&.mirror-marker {
|
|
297
|
+
opacity: 69%;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
&.has-progress::before {
|
|
301
|
+
content: '';
|
|
302
|
+
position: absolute;
|
|
303
|
+
inset: 2px;
|
|
304
|
+
border-radius: 50%;
|
|
305
|
+
background: conic-gradient(
|
|
306
|
+
var(--fg-strong) 0% var(--completion),
|
|
307
|
+
var(--fg-accent) 0% var(--direct-completion),
|
|
308
|
+
transparent 0% 100%
|
|
309
|
+
);
|
|
310
|
+
opacity: 0.65;
|
|
311
|
+
z-index: 0;
|
|
312
|
+
pointer-events: none;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
215
315
|
|
|
216
|
-
|
|
217
|
-
padding
|
|
316
|
+
input {
|
|
317
|
+
padding: 0.125em 0.25em;
|
|
218
318
|
}
|
|
219
319
|
}
|
|
220
320
|
|
|
@@ -239,4 +339,12 @@
|
|
|
239
339
|
padding: 0;
|
|
240
340
|
}
|
|
241
341
|
}
|
|
342
|
+
|
|
343
|
+
summary {
|
|
344
|
+
margin-bottom: 0.5em;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
summary:not(.completed-subtasks) {
|
|
348
|
+
font-weight: bold;
|
|
349
|
+
}
|
|
242
350
|
</style>
|
package/locales/en.json
CHANGED
|
@@ -11,21 +11,27 @@
|
|
|
11
11
|
"name": "Name",
|
|
12
12
|
"description": "Description <span class='subtle'>(optional)</span>"
|
|
13
13
|
},
|
|
14
|
+
"add_subtask": "Add Subtask",
|
|
14
15
|
"copy_id": "Copy ID",
|
|
15
16
|
"copy_link": "Copy Link",
|
|
16
17
|
"export_list": "Export",
|
|
17
18
|
"share_list": "Share",
|
|
18
|
-
"
|
|
19
|
-
"
|
|
19
|
+
"mark_all_pending": "Un-complete all",
|
|
20
|
+
"delete_completed": "Delete completed",
|
|
20
21
|
"open_new_tab": "Open in New Tab",
|
|
21
22
|
"new_task": "Add Task",
|
|
22
|
-
"pending_heading": "Pending",
|
|
23
23
|
"pending_empty": "No pending tasks.",
|
|
24
|
-
"completed_heading": "Completed",
|
|
25
|
-
"completed_empty": "No completed tasks.",
|
|
26
|
-
"toast_all_completed": "All tasks marked as complete",
|
|
24
|
+
"completed_heading": "Completed ({count})",
|
|
27
25
|
"toast_all_pending": "All tasks marked as pending",
|
|
28
26
|
"toast_task_deleted": "Task deleted",
|
|
29
|
-
"toast_list_deleted": "Task list deleted"
|
|
27
|
+
"toast_list_deleted": "Task list deleted",
|
|
28
|
+
"toast_delete_completed": "Removed completed tasks",
|
|
29
|
+
"preferences": {
|
|
30
|
+
"show_completed_subtasks": "Show completed subtasks"
|
|
31
|
+
},
|
|
32
|
+
"show_completed_subtasks": {
|
|
33
|
+
"inline": "Inline with pending",
|
|
34
|
+
"mirror": "In completed section"
|
|
35
|
+
}
|
|
30
36
|
}
|
|
31
37
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axium/tasks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"author": "James Prevett <axium@jamespre.dev>",
|
|
5
5
|
"description": "Tasks for Axium",
|
|
6
6
|
"funding": {
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"build": "tsc"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
|
-
"@axium/client": ">=0.
|
|
39
|
+
"@axium/client": ">=0.27.0",
|
|
40
40
|
"@axium/core": ">=0.23.0",
|
|
41
41
|
"@axium/server": ">=0.35.0",
|
|
42
42
|
"@sveltejs/kit": "^2.27.3",
|
package/routes/tasks/+layout.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { getCurrentSession } from '@axium/client/user';
|
|
2
2
|
import type { Session, UserPublic } from '@axium/core';
|
|
3
3
|
|
|
4
|
-
export
|
|
4
|
+
export const ssr = false;
|
|
5
|
+
|
|
6
|
+
export async function load({ parent }) {
|
|
5
7
|
let { session }: { session?: (Session & { user: UserPublic }) | null } = await parent();
|
|
6
8
|
|
|
7
9
|
session ||= await getCurrentSession().catch(() => null);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { FormDialog, Icon } from '@axium/client/components';
|
|
2
|
+
import { FormDialog, Icon, AppPreferencesDialog } from '@axium/client/components';
|
|
3
3
|
import { fetchAPI, text } from '@axium/client';
|
|
4
|
-
import { TaskListInit } from '@axium/tasks/common';
|
|
4
|
+
import { TaskListInit, TasksPreferences } from '@axium/tasks/common';
|
|
5
5
|
import { TaskList } from '@axium/tasks/components';
|
|
6
6
|
|
|
7
7
|
const { data } = $props();
|
|
@@ -17,14 +17,18 @@
|
|
|
17
17
|
<div id="tasks-main">
|
|
18
18
|
<h1>{text('app_name.tasks')}</h1>
|
|
19
19
|
|
|
20
|
-
<
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
<div>
|
|
21
|
+
<AppPreferencesDialog appId="tasks" userId={data.session.userId} schema={TasksPreferences} />
|
|
22
|
+
|
|
23
|
+
<button id="create-task-list" class="icon-text mobile-float-right" onclick={() => dialog!.showModal()}>
|
|
24
|
+
<Icon i="plus" />
|
|
25
|
+
<span>{text('tasks.new_list')}</span>
|
|
26
|
+
</button>
|
|
27
|
+
</div>
|
|
24
28
|
|
|
25
29
|
<div class="lists-container">
|
|
26
30
|
{#each lists as list}
|
|
27
|
-
<TaskList {list} bind:lists
|
|
31
|
+
<TaskList {list} bind:lists />
|
|
28
32
|
{/each}
|
|
29
33
|
</div>
|
|
30
34
|
</div>
|
|
@@ -71,6 +75,7 @@
|
|
|
71
75
|
@media (width < 700px) {
|
|
72
76
|
#tasks-main {
|
|
73
77
|
padding: 1em;
|
|
78
|
+
padding-bottom: 5em;
|
|
74
79
|
align-content: center;
|
|
75
80
|
}
|
|
76
81
|
|
package/routes/tasks/+page.ts
CHANGED