@axium/client 0.12.3 → 0.13.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/README.md CHANGED
@@ -1 +1,7 @@
1
1
  # Axium Client
2
+
3
+ Axium Client provides client-side functionality for Axium. This includes:
4
+
5
+ - UI components, which are currently used by the web UI
6
+ - The `axium-client`/`axc` CLI
7
+ - Some utility functions like `fetchAPI` that provide strongly-typed API interaction
@@ -0,0 +1,30 @@
1
+ @keyframes zoom {
2
+ from {
3
+ transform: scale(0.95);
4
+ }
5
+ to {
6
+ transform: scale(1);
7
+ }
8
+ }
9
+
10
+ @keyframes fade {
11
+ from {
12
+ opacity: 0;
13
+ }
14
+ to {
15
+ opacity: 1;
16
+ }
17
+ }
18
+
19
+ :root {
20
+ --A-zoom: zoom 0.25s cubic-bezier(0.35, 1.55, 0.65, 1);
21
+ --A-fade: fade 0.25s ease-out;
22
+ }
23
+
24
+ dialog[open] {
25
+ animation: var(--A-zoom);
26
+ }
27
+
28
+ dialog[open]::backdrop {
29
+ animation: var(--A-fade);
30
+ }
package/assets/styles.css CHANGED
@@ -1,3 +1,5 @@
1
+ @import './animations.css';
2
+
1
3
  * {
2
4
  box-sizing: border-box;
3
5
  color: hsl(0 0 var(--fg-light));
@@ -65,6 +67,21 @@ textarea {
65
67
  outline: none;
66
68
  }
67
69
 
70
+ label.checkbox {
71
+ cursor: pointer;
72
+ width: 1.5em;
73
+ height: 1.5em;
74
+ border: 1px solid var(--border-accent);
75
+ border-radius: 0.5em;
76
+ display: inline-flex;
77
+ justify-content: center;
78
+ align-items: center;
79
+ }
80
+
81
+ input[type='checkbox'] {
82
+ display: none;
83
+ }
84
+
68
85
  select,
69
86
  ::picker(select) {
70
87
  appearance: base-select;
@@ -118,34 +135,30 @@ dialog::backdrop {
118
135
  background: #0003;
119
136
  }
120
137
 
121
- dialog[open] {
122
- animation: zoom 0.25s cubic-bezier(0.35, 1.55, 0.65, 1);
138
+ dialog form {
139
+ display: contents;
123
140
  }
124
141
 
125
- @keyframes zoom {
126
- from {
127
- transform: scale(0.95);
128
- }
129
- to {
130
- transform: scale(1);
131
- }
142
+ progress {
143
+ appearance: none;
144
+ border: none;
145
+ height: 0.5em;
146
+ border-radius: 1em;
147
+ overflow: hidden;
148
+ background-color: var(--bg-normal);
149
+ border: 1px solid var(--border-accent);
132
150
  }
133
151
 
134
- dialog[open]::backdrop {
135
- animation: fade 0.25s ease-out;
152
+ progress::-webkit-progress-bar {
153
+ background-color: transparent;
136
154
  }
137
155
 
138
- @keyframes fade {
139
- from {
140
- opacity: 0;
141
- }
142
- to {
143
- opacity: 1;
144
- }
156
+ progress::-webkit-progress-value {
157
+ background-color: hsl(0 0 var(--fg-light));
145
158
  }
146
159
 
147
- dialog form {
148
- display: contents;
160
+ progress::-moz-progress-bar {
161
+ background-color: hsl(0 0 var(--fg-light));
149
162
  }
150
163
 
151
164
  :not(input).error {
package/dist/access.d.ts CHANGED
@@ -1,3 +1,5 @@
1
- import type { AccessControl, AccessMap } from '@axium/core';
2
- export declare function setACL(itemType: string, itemId: string, data: AccessMap): Promise<AccessControl[]>;
1
+ import type { AccessControl, AccessTarget } from '@axium/core';
2
+ export declare function updateACL(itemType: string, itemId: string, target: AccessTarget, permissions: Partial<Record<string, any>>): Promise<AccessControl>;
3
3
  export declare function getACL(itemType: string, itemId: string): Promise<AccessControl[]>;
4
+ export declare function addToACL(itemType: string, itemId: string, target: AccessTarget): Promise<AccessControl>;
5
+ export declare function removeFromACL(itemType: string, itemId: string, target: AccessTarget): Promise<AccessControl>;
package/dist/access.js CHANGED
@@ -1,7 +1,13 @@
1
1
  import { fetchAPI } from './requests.js';
2
- export async function setACL(itemType, itemId, data) {
3
- return await fetchAPI('POST', 'acl/:itemType/:itemId', data, itemType, itemId);
2
+ export async function updateACL(itemType, itemId, target, permissions) {
3
+ return await fetchAPI('PATCH', 'acl/:itemType/:itemId', { target, permissions }, itemType, itemId);
4
4
  }
5
5
  export async function getACL(itemType, itemId) {
6
6
  return await fetchAPI('GET', 'acl/:itemType/:itemId', {}, itemType, itemId);
7
7
  }
8
+ export async function addToACL(itemType, itemId, target) {
9
+ return await fetchAPI('PUT', 'acl/:itemType/:itemId', target, itemType, itemId);
10
+ }
11
+ export async function removeFromACL(itemType, itemId, target) {
12
+ return await fetchAPI('DELETE', 'acl/:itemType/:itemId', target, itemType, itemId);
13
+ }
package/dist/requests.js CHANGED
@@ -49,6 +49,11 @@ export async function fetchAPI(method, endpoint, data, ...params) {
49
49
  const json = await response.json().catch(() => ({ message: 'Unknown server error (invalid JSON response)' }));
50
50
  if (!response.ok)
51
51
  throw new Error(json.message);
52
+ if (typeof json == 'object' && json != null && '_warnings' in json) {
53
+ for (const warning of json._warnings)
54
+ console.warn('[API]', warning);
55
+ delete json._warnings;
56
+ }
52
57
  if (!schema)
53
58
  return json;
54
59
  const Output = Array.isArray(schema) ? schema[1] : schema;
@@ -56,6 +61,6 @@ export async function fetchAPI(method, endpoint, data, ...params) {
56
61
  return Output.parse(json);
57
62
  }
58
63
  catch (e) {
59
- throw prettifyError(e);
64
+ throw `${method} ${endpoint}:\n${prettifyError(e)}`;
60
65
  }
61
66
  }
@@ -1,43 +1,46 @@
1
1
  <script lang="ts">
2
- import { getACL, setACL } from '@axium/client/access';
2
+ import { addToACL, getACL, updateACL } from '@axium/client/access';
3
3
  import { userInfo } from '@axium/client/user';
4
- import type { AccessMap, User } from '@axium/core';
5
- import { pickPermissions, type AccessControl, type AccessControllable } from '@axium/core/access';
6
- import FormDialog from './FormDialog.svelte';
7
- import UserCard from './UserCard.svelte';
8
- import Icon from './Icon.svelte';
4
+ import type { AccessControllable, AccessTarget, User } from '@axium/core';
5
+ import { getTarget, pickPermissions } from '@axium/core/access';
6
+ import { errorText } from '@axium/core/io';
9
7
  import type { HTMLDialogAttributes } from 'svelte/elements';
8
+ import Icon from './Icon.svelte';
9
+ import UserCard from './UserCard.svelte';
10
+ import UserDiscovery from './UserDiscovery.svelte';
10
11
 
11
12
  interface Props extends HTMLDialogAttributes {
12
13
  editable: boolean;
13
14
  dialog?: HTMLDialogElement;
14
15
  itemType: string;
15
- item?: ({ name?: string; user?: User; id: string } & AccessControllable) | null;
16
- acl?: AccessControl[];
16
+ item: { name?: string; user?: User; id: string } & AccessControllable;
17
17
  }
18
- let { item = $bindable(), itemType, editable, dialog = $bindable(), acl = $bindable(item?.acl), ...rest }: Props = $props();
18
+ let { item, itemType, editable, dialog = $bindable(), ...rest }: Props = $props();
19
+
20
+ let error = $state<string>();
21
+
22
+ const acl = $state(item.acl ?? (await getACL(itemType, item.id)));
19
23
 
20
- if (!acl && item) getACL(itemType, item.id).then(fetched => (acl = item.acl = fetched));
24
+ async function onSelect(target: AccessTarget) {
25
+ const control = await addToACL(itemType, item.id, target);
26
+ if (control.userId) control.user = await userInfo(control.userId);
27
+ acl.push(control);
28
+ }
21
29
  </script>
22
30
 
23
- <FormDialog
24
- bind:dialog
25
- submitText="Save"
26
- submit={async data => {
27
- if (item) await setACL(itemType, item.id, data as any as AccessMap);
28
- }}
29
- {...rest}
30
- >
31
- {#snippet header()}
32
- {#if item?.name}
33
- <h3>Permissions for <strong>{item.name}</strong></h3>
34
- {:else}
35
- <h3>Permissions</h3>
36
- {/if}
37
- {/snippet}
31
+ <dialog bind:this={dialog} {...rest}>
32
+ {#if item.name}
33
+ <h3>Permissions for <strong>{item.name}</strong></h3>
34
+ {:else}
35
+ <h3>Permissions</h3>
36
+ {/if}
37
+
38
+ {#if error}
39
+ <div class="error">{error}</div>
40
+ {/if}
38
41
 
39
42
  <div class="AccessControl">
40
- {#if item?.user}
43
+ {#if item.user}
41
44
  <UserCard user={item.user} />
42
45
  {:else if item}
43
46
  {#await userInfo(item.userId) then user}<UserCard {user} />{/await}
@@ -45,7 +48,15 @@
45
48
  <span>Owner</span>
46
49
  </div>
47
50
 
48
- {#each acl ?? [] as control}
51
+ {#each acl as control}
52
+ {@const update = (key: string) => async (e: Event & { currentTarget: HTMLInputElement }) => {
53
+ try {
54
+ const updated = await updateACL(itemType, item.id, getTarget(control), { [key]: e.currentTarget.checked });
55
+ Object.assign(control, updated);
56
+ } catch (e) {
57
+ error = errorText(e);
58
+ }
59
+ }}
49
60
  <div class="AccessControl">
50
61
  {#if control.user}
51
62
  <UserCard user={control.user} />
@@ -58,25 +69,49 @@
58
69
  {:else}
59
70
  <i>Unknown</i>
60
71
  {/if}
61
- {#if editable}
62
- <select name={control.userId ?? (control.role ? '@' + control.role : 'public')} multiple>
63
- {#each Object.entries(pickPermissions(control)) as [key, value]}
64
- <option value={key} selected={!!value}>{key}</option>
65
- {/each}
66
- </select>
67
- {:else}
68
- <span
69
- >{Object.entries(pickPermissions(control))
70
- .filter(([, value]) => value)
71
- .map(([key]) => key)
72
- .join(', ')}</span
73
- >
74
- {/if}
72
+ <div class="permissions">
73
+ {#each Object.entries(pickPermissions(control) as Record<string, boolean>) as [key, value]}
74
+ {@const id = `${getTarget(control)}.${key}`}
75
+ <span class="icon-text">
76
+ {#if editable}
77
+ <input {id} type="checkbox" onchange={update(key)} />
78
+ <label for={id} class="checkbox">
79
+ {#if value}<Icon i="check" --size="1.3em" />{/if}
80
+ </label>
81
+ {:else}
82
+ <Icon i={value ? 'check' : 'xmark'} />
83
+ {/if}
84
+ <span>{key}</span>
85
+ </span>
86
+ {/each}
87
+ </div>
75
88
  </div>
76
89
  {/each}
77
- </FormDialog>
90
+
91
+ <UserDiscovery {onSelect} excludeTargets={acl.map(getTarget)} />
92
+
93
+ <div>
94
+ <button class="done" onclick={() => dialog!.close()}>Done</button>
95
+ </div>
96
+ </dialog>
78
97
 
79
98
  <style>
99
+ dialog:open {
100
+ display: flex;
101
+ flex-direction: column;
102
+ gap: 1em;
103
+ }
104
+
105
+ .done {
106
+ float: right;
107
+ }
108
+
109
+ .permissions {
110
+ display: flex;
111
+ flex-direction: column;
112
+ gap: 0.1em;
113
+ }
114
+
80
115
  .AccessControl {
81
116
  display: grid;
82
117
  gap: 1em;
package/lib/Upload.svelte CHANGED
@@ -7,17 +7,23 @@
7
7
  name = 'files',
8
8
  input = $bindable(),
9
9
  files = $bindable(),
10
+ progress = $bindable(),
10
11
  ...rest
11
- }: HTMLInputAttributes & { input?: HTMLInputElement } = $props();
12
+ }: HTMLInputAttributes & { input?: HTMLInputElement; progress?: [current: number, max: number][] } = $props();
12
13
 
13
14
  const id = $props.id();
14
15
  </script>
15
16
 
16
17
  <div>
17
18
  <label for={id} class={[files?.length && 'file']}>
18
- {#each files! as file}
19
+ {#each files! as file, i}
19
20
  <Icon i={forMime(file.type)} />
20
- <span>{file.name}</span>
21
+ <div class="name">
22
+ <span>{file.name}</span>
23
+ {#if progress?.[i]}
24
+ <progress value={progress[i][0]} max={progress[i][1]}></progress>
25
+ {/if}
26
+ </div>
21
27
  <button
22
28
  onclick={e => {
23
29
  e.preventDefault();
@@ -53,6 +59,18 @@
53
59
  width: 20em;
54
60
  }
55
61
 
62
+ .name {
63
+ display: flex;
64
+ flex-direction: column;
65
+ justify-content: center;
66
+ min-width: 0;
67
+ }
68
+
69
+ progress {
70
+ width: 100%;
71
+ height: 4px;
72
+ }
73
+
56
74
  label.file {
57
75
  display: grid;
58
76
  grid-template-columns: 2em 1fr 2em;
@@ -0,0 +1,144 @@
1
+ <script lang="ts">
2
+ import { fetchAPI } from '@axium/client/requests';
3
+ import { getUserImage, type AccessTarget, type UserPublic } from '@axium/core';
4
+ import { colorHash } from '@axium/core/color';
5
+ import Icon from './Icon.svelte';
6
+ import { errorText } from '@axium/core/io';
7
+
8
+ const {
9
+ onSelect,
10
+ enableTags = false,
11
+ excludeTargets = [],
12
+ }: {
13
+ onSelect(target: AccessTarget): unknown;
14
+ enableTags?: boolean;
15
+ excludeTargets?: string[];
16
+ } = $props();
17
+
18
+ type Result = { type: 'user'; value: UserPublic; target: string } | { type: 'role' | 'tag'; value: string; target: string };
19
+
20
+ let results = $state<Result[]>([]);
21
+ let value = $state<string>();
22
+ let gotError = $state<boolean>(false);
23
+
24
+ async function onchange() {
25
+ if (!value || !value.length) {
26
+ results = [];
27
+ return;
28
+ }
29
+
30
+ try {
31
+ const users = await fetchAPI('POST', 'users/discover', value);
32
+ results = [
33
+ ...users.map(value => ({ type: 'user', value, target: value.id }) as const),
34
+ { type: 'role', value, target: '@' + value } as const,
35
+ enableTags && ({ type: 'tag', value, target: '#' + value } as const),
36
+ ].filter<Result>(r => !!r);
37
+ } catch (e) {
38
+ gotError = true;
39
+ console.warn('Can not use user discovery:', errorText(e));
40
+ results = [];
41
+ }
42
+ }
43
+
44
+ function select(target: string) {
45
+ return (e: Event) => {
46
+ e.stopPropagation();
47
+ onSelect(target);
48
+ results = [];
49
+ value = '';
50
+ };
51
+ }
52
+ </script>
53
+
54
+ <input bind:value type="text" placeholder="Add users and roles" {onchange} onkeyup={onchange} />
55
+ {#if !gotError}
56
+ <!-- Don't show results when we can't use the discovery API -->
57
+ <div class="results">
58
+ {#each results as result}
59
+ {#if !excludeTargets.includes(result.target)}
60
+ <div class="result" onclick={select(result.target)}>
61
+ {#if result.type == 'user'}
62
+ <span><img src={getUserImage(result.value)} alt={result.value.name} />{result.value.name}</span>
63
+ {:else if result.type == 'role'}
64
+ <span>
65
+ <span class="icon-text tag-or-role" style:background-color={colorHash(result.value)}
66
+ ><Icon i="at" />{result.value}</span
67
+ >
68
+ </span>
69
+ {:else if result.type == 'tag'}
70
+ <span>
71
+ <span class="icon-text tag-or-role" style:background-color={colorHash(result.value)}
72
+ ><Icon i="hashtag" />{result.value}</span
73
+ >
74
+ </span>
75
+ {/if}
76
+ </div>
77
+ {/if}
78
+ {:else}
79
+ <i>No results</i>
80
+ {/each}
81
+ </div>
82
+ {/if}
83
+
84
+ <style>
85
+ :host {
86
+ anchor-scope: --discovery-input;
87
+ }
88
+
89
+ input {
90
+ anchor-name: --discovery-input;
91
+ }
92
+
93
+ input:focus + .results,
94
+ .results:active {
95
+ display: flex;
96
+ animation: var(--A-zoom);
97
+ }
98
+
99
+ .results {
100
+ position: fixed;
101
+ position-anchor: --discovery-input;
102
+ inset: anchor(bottom) anchor(right) auto anchor(left);
103
+ display: none;
104
+ flex-direction: column;
105
+ gap: 0.25em;
106
+ height: fit-content;
107
+ max-height: 25em;
108
+ background-color: var(--bg-accent);
109
+ border-radius: 0.25em 0.25em 0.75em 0.75em;
110
+ padding: 1em;
111
+ border: 1px solid var(--border-accent);
112
+ align-items: stretch;
113
+
114
+ i {
115
+ text-align: center;
116
+ }
117
+ }
118
+
119
+ .result {
120
+ padding: 0.5em;
121
+ border-radius: 0.5em;
122
+ }
123
+
124
+ .result:hover {
125
+ cursor: pointer;
126
+ background-color: var(--bg-strong);
127
+ }
128
+
129
+ .tag-or-role {
130
+ border-radius: 1em;
131
+ padding: 0.25em 0.75em;
132
+ display: inline-flex;
133
+ align-items: center;
134
+ gap: 0.25em;
135
+ }
136
+
137
+ img {
138
+ width: 2em;
139
+ height: 2em;
140
+ border-radius: 50%;
141
+ vertical-align: middle;
142
+ margin-right: 0.5em;
143
+ }
144
+ </style>
@@ -204,21 +204,6 @@
204
204
  {/if}
205
205
 
206
206
  <style>
207
- input[type='checkbox'] {
208
- display: none;
209
- }
210
-
211
- label.checkbox {
212
- cursor: pointer;
213
- width: 1.5em;
214
- height: 1.5em;
215
- border: 1px solid var(--border-accent);
216
- border-radius: 0.5em;
217
- display: inline-flex;
218
- justify-content: center;
219
- align-items: center;
220
- }
221
-
222
207
  .ZodInput-error {
223
208
  position: fixed;
224
209
  position-anchor: --zod-input;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axium/client",
3
- "version": "0.12.3",
3
+ "version": "0.13.1",
4
4
  "author": "James Prevett <jp@jamespre.dev>",
5
5
  "funding": {
6
6
  "type": "individual",
@@ -40,7 +40,7 @@
40
40
  "build": "tsc"
41
41
  },
42
42
  "peerDependencies": {
43
- "@axium/core": ">=0.18.0",
43
+ "@axium/core": ">=0.19.0",
44
44
  "utilium": "^2.3.8",
45
45
  "zod": "^4.0.5",
46
46
  "svelte": "^5.36.0"
package/styles/list.css CHANGED
@@ -40,7 +40,6 @@
40
40
 
41
41
  .list-item:not(.list-header):hover {
42
42
  background-color: var(--bg-alt);
43
- cursor: pointer;
44
43
  }
45
44
 
46
45
  p.list-empty {