@axium/client 0.12.2 → 0.13.0

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.
@@ -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,32 +135,6 @@ 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);
123
- }
124
-
125
- @keyframes zoom {
126
- from {
127
- transform: scale(0.95);
128
- }
129
- to {
130
- transform: scale(1);
131
- }
132
- }
133
-
134
- dialog[open]::backdrop {
135
- animation: fade 0.25s ease-out;
136
- }
137
-
138
- @keyframes fade {
139
- from {
140
- opacity: 0;
141
- }
142
- to {
143
- opacity: 1;
144
- }
145
- }
146
-
147
138
  dialog form {
148
139
  display: contents;
149
140
  }
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;
@@ -0,0 +1,112 @@
1
+ <script lang="ts">
2
+ import Icon from './Icon.svelte';
3
+ import { capitalize } from 'utilium';
4
+
5
+ interface Tab {
6
+ href: string;
7
+ name: string;
8
+ icon: string;
9
+ active: boolean;
10
+ }
11
+
12
+ let { children, tabs, bottom }: { children(): any; tabs: Tab[]; bottom?(): any } = $props();
13
+ </script>
14
+
15
+ <div class="sidebar-container">
16
+ <div class="sidebar">
17
+ {#each tabs as { href, name, icon: i, active }}
18
+ <a {href} class={['item', 'icon-text', active && 'active']}><Icon {i} /> <span class="sidebar-text">{capitalize(name)}</span></a
19
+ >
20
+ {/each}
21
+
22
+ {#if bottom}
23
+ <div class="sidebar-bottom">
24
+ {@render bottom()}
25
+ </div>
26
+ {/if}
27
+ </div>
28
+
29
+ <div class="sidebar-content">
30
+ {@render children()}
31
+ </div>
32
+ </div>
33
+
34
+ <style>
35
+ .sidebar-container {
36
+ display: grid;
37
+ grid-template-columns: 15em 1fr;
38
+ height: 100%;
39
+ }
40
+
41
+ .sidebar {
42
+ grid-column: 1;
43
+ width: 100%;
44
+ display: inline-flex;
45
+ flex-direction: column;
46
+ gap: 0.5em;
47
+ background-color: var(--bg-alt);
48
+ padding: 1em;
49
+ padding-left: 0;
50
+ border-radius: 0 1em 1em 0;
51
+
52
+ .item {
53
+ padding: 0.3em 0.5em;
54
+ border-radius: 0.25em 1em 1em 0.25em;
55
+ }
56
+
57
+ .item:hover {
58
+ background-color: var(--bg-strong);
59
+ cursor: pointer;
60
+ }
61
+
62
+ .item.active {
63
+ background-color: var(--bg-strong);
64
+ }
65
+ }
66
+
67
+ .sidebar-content {
68
+ grid-column: 2;
69
+ padding: 1em;
70
+ overflow-x: hidden;
71
+ overflow-y: scroll;
72
+ }
73
+
74
+ .sidebar-bottom {
75
+ margin-top: auto;
76
+ }
77
+
78
+ @media (width < 700px) {
79
+ .sidebar-container {
80
+ grid-template-columns: 1fr;
81
+ }
82
+
83
+ .sidebar-content {
84
+ padding-bottom: 4em;
85
+ grid-column: 1;
86
+ }
87
+
88
+ .sidebar {
89
+ position: fixed;
90
+ grid-column: unset;
91
+ inset: auto 0 0;
92
+ border-radius: 1em;
93
+ display: flex;
94
+ flex-direction: row;
95
+ justify-content: space-around;
96
+ gap: 1em;
97
+ padding: 0.5em;
98
+ z-index: 6;
99
+
100
+ .item {
101
+ flex: 1 1 0;
102
+ border-radius: 1em;
103
+ padding: 1em;
104
+ justify-content: center;
105
+ }
106
+ }
107
+
108
+ .sidebar-text {
109
+ display: none;
110
+ }
111
+ }
112
+ </style>
@@ -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/lib/index.ts CHANGED
@@ -9,6 +9,7 @@ export { default as NumberBar } from './NumberBar.svelte';
9
9
  export { default as Popover } from './Popover.svelte';
10
10
  export { default as Register } from './Register.svelte';
11
11
  export { default as SessionList } from './SessionList.svelte';
12
+ export { default as SidebarLayout } from './SidebarLayout.svelte';
12
13
  export { default as Toast } from './Toast.svelte';
13
14
  export { default as Upload } from './Upload.svelte';
14
15
  export { default as URLText } from './URLText.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axium/client",
3
- "version": "0.12.2",
3
+ "version": "0.13.0",
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 {