@axium/client 0.33.4 → 0.34.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.
- package/assets/styles.css +36 -0
- package/dist/locales.d.ts +1 -0
- package/lib/AccessControlDialog.svelte +2 -2
- package/lib/FormDialog.svelte +2 -2
- package/lib/InlineEdit.svelte +117 -0
- package/lib/MediaControls.svelte +25 -6
- package/lib/Upload.svelte +2 -1
- package/lib/index.ts +1 -0
- package/lib/reactive/media.svelte.ts +8 -1
- package/lib/toast.ts +71 -1
- package/locales/en.json +1 -0
- package/package.json +1 -1
- package/styles/account.css +1 -1
package/assets/styles.css
CHANGED
|
@@ -441,6 +441,42 @@ div.toast {
|
|
|
441
441
|
border: var(--border-accent);
|
|
442
442
|
background-color: var(--bg-accent);
|
|
443
443
|
}
|
|
444
|
+
|
|
445
|
+
&.progress {
|
|
446
|
+
border: var(--border-accent);
|
|
447
|
+
background-color: var(--bg-accent);
|
|
448
|
+
display: flex;
|
|
449
|
+
flex-direction: column;
|
|
450
|
+
gap: 0.25em;
|
|
451
|
+
text-align: left;
|
|
452
|
+
min-width: 16em;
|
|
453
|
+
|
|
454
|
+
.toast-header {
|
|
455
|
+
display: flex;
|
|
456
|
+
align-items: center;
|
|
457
|
+
justify-content: space-between;
|
|
458
|
+
gap: 1em;
|
|
459
|
+
|
|
460
|
+
button {
|
|
461
|
+
display: inline-flex;
|
|
462
|
+
cursor: pointer;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
.subtle {
|
|
467
|
+
opacity: 69%;
|
|
468
|
+
font-size: 0.85em;
|
|
469
|
+
|
|
470
|
+
&:empty {
|
|
471
|
+
display: none;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
progress {
|
|
476
|
+
width: 100%;
|
|
477
|
+
height: 4px;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
444
480
|
}
|
|
445
481
|
|
|
446
482
|
@media (width >= 700px) {
|
package/dist/locales.d.ts
CHANGED
|
@@ -129,6 +129,7 @@ declare let currentLoaded: {
|
|
|
129
129
|
readonly preferences: "Preferences";
|
|
130
130
|
readonly sessions: "Sessions";
|
|
131
131
|
readonly title: "Your Account";
|
|
132
|
+
readonly toast_username_updated: "Username updated";
|
|
132
133
|
readonly user_id: "User ID";
|
|
133
134
|
readonly user_id_hint: "This is your UUID. It can't be changed.";
|
|
134
135
|
readonly verification_sent: "Verification email sent";
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
</div>
|
|
40
40
|
|
|
41
41
|
{#each acl as control, i (control.userId || control.role || control.tag)}
|
|
42
|
-
{
|
|
42
|
+
{const update = (key: string) => async (e: Event & { currentTarget: HTMLInputElement }) => {
|
|
43
43
|
try {
|
|
44
44
|
const updated = await updateACL(itemType, item.id, getTarget(control), { [key]: e.currentTarget.checked });
|
|
45
45
|
Object.assign(control, updated);
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
</div>
|
|
81
81
|
<div class="permissions">
|
|
82
82
|
{#each Object.entries(pickPermissions(control) as Record<string, boolean>) as [key, value]}
|
|
83
|
-
{
|
|
83
|
+
{const id = $derived(`${item.id}.${getTarget(control)}.${key}`)}
|
|
84
84
|
<span class="icon-text">
|
|
85
85
|
{#if editable}
|
|
86
86
|
<input {id} type="checkbox" onchange={update(key)} />
|
package/lib/FormDialog.svelte
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
/** Change the text displayed for the submit button */
|
|
25
25
|
submitText?: string;
|
|
26
26
|
/** Called on submission, this should do the actual submission */
|
|
27
|
-
submit?(data: Record<string, FormDataEntryValue>):
|
|
27
|
+
submit?(data: Record<string, FormDataEntryValue>): any;
|
|
28
28
|
/** Whether to display the dialog as a full-page form */
|
|
29
29
|
pageMode?: boolean;
|
|
30
30
|
submitDanger?: boolean;
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
function onsubmit(e: SubmitEvent & { currentTarget: HTMLFormElement }) {
|
|
48
48
|
e.preventDefault();
|
|
49
49
|
const data = Object.fromEntries(new FormData(e.currentTarget));
|
|
50
|
-
submit(data)
|
|
50
|
+
Promise.resolve(submit(data))
|
|
51
51
|
.then(result => {
|
|
52
52
|
if (!pageMode) dialog!.close();
|
|
53
53
|
})
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { text } from '@axium/client';
|
|
3
|
+
import { prettifyError, type ZodType } from 'zod';
|
|
4
|
+
import Icon from './Icon.svelte';
|
|
5
|
+
|
|
6
|
+
let {
|
|
7
|
+
value,
|
|
8
|
+
commit,
|
|
9
|
+
close,
|
|
10
|
+
schema,
|
|
11
|
+
optional = false,
|
|
12
|
+
selectionRange,
|
|
13
|
+
placeholder,
|
|
14
|
+
confirmLabel = text('generic.change'),
|
|
15
|
+
}: {
|
|
16
|
+
/** The committed value being edited. */
|
|
17
|
+
value?: string | null;
|
|
18
|
+
/** Called with the new value when a change is confirmed. `null` means the value was cleared (only possible when `optional`). */
|
|
19
|
+
commit(value: string | null): unknown;
|
|
20
|
+
/** Called when editing ends, whether or not a change was committed. */
|
|
21
|
+
close(): void;
|
|
22
|
+
/** Validates the draft as the user types and before committing. Errors float above the input. */
|
|
23
|
+
schema?: ZodType;
|
|
24
|
+
/** Whether clearing the input commits `null` instead of closing without changes. */
|
|
25
|
+
optional?: boolean;
|
|
26
|
+
/** The range of the value to select when editing starts. Defaults to the entire value. */
|
|
27
|
+
selectionRange?: [number, number];
|
|
28
|
+
placeholder?: string;
|
|
29
|
+
confirmLabel?: string;
|
|
30
|
+
} = $props();
|
|
31
|
+
|
|
32
|
+
let draft = $state(value ?? ''),
|
|
33
|
+
error = $state<string>();
|
|
34
|
+
|
|
35
|
+
function validate(val: string | null): boolean {
|
|
36
|
+
const result = val === null && optional ? undefined : schema?.safeParse(val);
|
|
37
|
+
error = result?.error ? prettifyError(result.error) : undefined;
|
|
38
|
+
return !result || result.success;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function confirm() {
|
|
42
|
+
const newValue = draft.trim() || null;
|
|
43
|
+
if (newValue === (value ?? null) || (newValue === null && !optional)) return close();
|
|
44
|
+
if (!validate(newValue)) return;
|
|
45
|
+
commit(newValue);
|
|
46
|
+
close();
|
|
47
|
+
}
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<span
|
|
51
|
+
class="InlineEdit"
|
|
52
|
+
data-no-select
|
|
53
|
+
onclick={e => e.stopPropagation()}
|
|
54
|
+
{@attach span => {
|
|
55
|
+
const input = span.querySelector('input')!;
|
|
56
|
+
requestAnimationFrame(() => {
|
|
57
|
+
input.focus();
|
|
58
|
+
input.setSelectionRange(...(selectionRange ?? [0, draft.length]));
|
|
59
|
+
});
|
|
60
|
+
const onOutside = (e: PointerEvent) => !span.contains(e.target as Node) && close();
|
|
61
|
+
requestAnimationFrame(() => document.addEventListener('pointerdown', onOutside, true));
|
|
62
|
+
return () => document.removeEventListener('pointerdown', onOutside, true);
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
{#if error}<span class="InlineEdit-error">{error}</span>{/if}
|
|
66
|
+
<input
|
|
67
|
+
bind:value={draft}
|
|
68
|
+
{placeholder}
|
|
69
|
+
class={[error && 'error']}
|
|
70
|
+
oninput={() => validate(draft.trim() || null)}
|
|
71
|
+
onkeydown={e => {
|
|
72
|
+
if (e.key == 'Enter') confirm();
|
|
73
|
+
else if (e.key == 'Escape') close();
|
|
74
|
+
}}
|
|
75
|
+
/>
|
|
76
|
+
<button class="reset InlineEdit-confirm" aria-label={confirmLabel} onclick={confirm}>
|
|
77
|
+
<Icon i="check" />
|
|
78
|
+
</button>
|
|
79
|
+
</span>
|
|
80
|
+
|
|
81
|
+
<style>
|
|
82
|
+
.InlineEdit {
|
|
83
|
+
display: flex;
|
|
84
|
+
align-items: center;
|
|
85
|
+
gap: 0.5em;
|
|
86
|
+
min-width: 0;
|
|
87
|
+
anchor-scope: --inline-edit;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
input {
|
|
91
|
+
background: var(--bg-normal);
|
|
92
|
+
border: var(--border-accent);
|
|
93
|
+
border-radius: 0.25em;
|
|
94
|
+
padding: 0.1em 0.25em;
|
|
95
|
+
margin: -0.1em 0;
|
|
96
|
+
font: inherit;
|
|
97
|
+
color: inherit;
|
|
98
|
+
min-width: 0;
|
|
99
|
+
flex: 1 1 auto;
|
|
100
|
+
anchor-name: --inline-edit;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.InlineEdit-error {
|
|
104
|
+
position: fixed;
|
|
105
|
+
position-anchor: --inline-edit;
|
|
106
|
+
bottom: calc(anchor(top) - 0.3em);
|
|
107
|
+
left: anchor(left);
|
|
108
|
+
color: var(--fg-error);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.InlineEdit-confirm {
|
|
112
|
+
display: inline-flex;
|
|
113
|
+
align-items: center;
|
|
114
|
+
flex: 0 0 auto;
|
|
115
|
+
cursor: pointer;
|
|
116
|
+
}
|
|
117
|
+
</style>
|
package/lib/MediaControls.svelte
CHANGED
|
@@ -10,27 +10,38 @@
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
const { media, children }: Props = $props();
|
|
13
|
+
|
|
14
|
+
const duration = $derived(media.knownDuration);
|
|
13
15
|
</script>
|
|
14
16
|
|
|
15
17
|
<div class="MediaControls">
|
|
16
18
|
<button class="reset icon-text" onclick={media.click}>
|
|
17
19
|
<Icon i={media.ended ? 'arrow-rotate-right' : media.paused ? 'play' : 'pause'} />
|
|
18
20
|
</button>
|
|
19
|
-
<div class=
|
|
21
|
+
<div class={['timeline', !duration && 'unknown-duration']}>
|
|
20
22
|
<div class="timeline-track">
|
|
21
23
|
{#each media.buffered || [] as { start, end }}
|
|
22
24
|
<div
|
|
23
25
|
class="buffered-range"
|
|
24
|
-
style:left="{(start /
|
|
25
|
-
style:width="{((end - start) /
|
|
26
|
+
style:left="{(start / duration) * 100}%"
|
|
27
|
+
style:width="{((end - start) / duration) * 100}%"
|
|
26
28
|
></div>
|
|
27
29
|
{/each}
|
|
28
|
-
<div class="played-range" style:width="{(media.currentTime /
|
|
30
|
+
<div class="played-range" style:width="{(media.currentTime / duration) * 100}%"></div>
|
|
29
31
|
</div>
|
|
30
|
-
<div class="timeline-thumb" style:left="{(media.currentTime /
|
|
32
|
+
<div class="timeline-thumb" style:left="{(media.currentTime / duration) * 100}%">
|
|
31
33
|
<Icon i="pipe" />
|
|
32
34
|
</div>
|
|
33
|
-
|
|
35
|
+
|
|
36
|
+
<input
|
|
37
|
+
class="seek-input"
|
|
38
|
+
type="range"
|
|
39
|
+
min="0"
|
|
40
|
+
max={duration || 1}
|
|
41
|
+
step="0.01"
|
|
42
|
+
disabled={!duration}
|
|
43
|
+
bind:value={media.currentTime}
|
|
44
|
+
/>
|
|
34
45
|
</div>
|
|
35
46
|
<div class="times">
|
|
36
47
|
<span>{formatDuration(media.currentTime)}</span>
|
|
@@ -69,6 +80,14 @@
|
|
|
69
80
|
height: 8px;
|
|
70
81
|
display: flex;
|
|
71
82
|
align-items: center;
|
|
83
|
+
|
|
84
|
+
&.unknown-duration {
|
|
85
|
+
opacity: 0.5;
|
|
86
|
+
|
|
87
|
+
.seek-input {
|
|
88
|
+
cursor: default;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
72
91
|
}
|
|
73
92
|
|
|
74
93
|
.timeline-track {
|
package/lib/Upload.svelte
CHANGED
package/lib/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export { default as Discovery } from './Discovery.svelte';
|
|
|
9
9
|
export * as discovery from './Discovery.svelte';
|
|
10
10
|
export { default as FormDialog } from './FormDialog.svelte';
|
|
11
11
|
export { default as Icon } from './Icon.svelte';
|
|
12
|
+
export { default as InlineEdit } from './InlineEdit.svelte';
|
|
12
13
|
export { default as LocationSelect } from './LocationSelect.svelte';
|
|
13
14
|
export { default as Login } from './Login.svelte';
|
|
14
15
|
export { default as Logout } from './Logout.svelte';
|
|
@@ -65,15 +65,22 @@ export class MediaState {
|
|
|
65
65
|
}
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
+
/** The duration, or 0 when it isn't known yet (NaN until metadata loads, Infinity for streams) */
|
|
69
|
+
get knownDuration(): number {
|
|
70
|
+
return Number.isFinite(this.duration) && this.duration > 0 ? this.duration : 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
68
73
|
keydown = (e: KeyboardEvent) => {
|
|
69
74
|
switch (e.key) {
|
|
70
75
|
case 'ArrowLeft':
|
|
71
76
|
e.preventDefault();
|
|
77
|
+
if (!this.knownDuration) break;
|
|
72
78
|
this.currentTime = Math.max(0, this.currentTime - 10);
|
|
73
79
|
break;
|
|
74
80
|
case 'ArrowRight':
|
|
75
81
|
e.preventDefault();
|
|
76
|
-
|
|
82
|
+
if (!this.knownDuration) break;
|
|
83
|
+
this.currentTime = Math.min(this.knownDuration, this.currentTime + 10);
|
|
77
84
|
break;
|
|
78
85
|
case 'ArrowUp':
|
|
79
86
|
this.volume = Math.min(1, this.volume + 0.1);
|
package/lib/toast.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { debug, errorText } from 'ioium';
|
|
1
|
+
import { debug, errorText, useProgress } from 'ioium';
|
|
2
2
|
import { text } from '@axium/client/locales';
|
|
3
3
|
import { animate, onAnimationEnd } from '@axium/client/gui';
|
|
4
4
|
import Icon from './Icon.svelte';
|
|
@@ -83,4 +83,74 @@ export async function toastStatus(promise: Promise<unknown>, successMessage: str
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
let progressToast: HTMLDivElement | undefined,
|
|
87
|
+
progressMain: HTMLSpanElement,
|
|
88
|
+
progressMessage: HTMLSpanElement,
|
|
89
|
+
progressBar: HTMLProgressElement,
|
|
90
|
+
progressCancelButton: HTMLButtonElement,
|
|
91
|
+
progressCancel: (() => void) | undefined;
|
|
92
|
+
|
|
93
|
+
function warnBeforeUnload(e: BeforeUnloadEvent) {
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Register a callback used to cancel the operation shown in the progress toast.
|
|
99
|
+
* The toast's cancel button is only shown while a callback is registered.
|
|
100
|
+
*/
|
|
101
|
+
export function setProgressCancel(cancel?: () => void): void {
|
|
102
|
+
progressCancel = cancel;
|
|
103
|
+
if (progressToast) progressCancelButton.style.display = cancel ? '' : 'none';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
useProgress({
|
|
107
|
+
start(message: string): void {
|
|
108
|
+
if (!progressToast) {
|
|
109
|
+
progressToast = document.createElement('div');
|
|
110
|
+
progressToast.classList.add('toast', 'progress');
|
|
111
|
+
|
|
112
|
+
const header = document.createElement('div');
|
|
113
|
+
header.classList.add('toast-header');
|
|
114
|
+
progressToast.appendChild(header);
|
|
115
|
+
|
|
116
|
+
progressMain = document.createElement('span');
|
|
117
|
+
header.appendChild(progressMain);
|
|
118
|
+
|
|
119
|
+
progressCancelButton = document.createElement('button');
|
|
120
|
+
progressCancelButton.classList.add('reset');
|
|
121
|
+
progressCancelButton.onclick = () => progressCancel?.();
|
|
122
|
+
mount(Icon, { target: progressCancelButton, props: { i: 'xmark-large' } });
|
|
123
|
+
header.appendChild(progressCancelButton);
|
|
124
|
+
|
|
125
|
+
progressMessage = document.createElement('span');
|
|
126
|
+
progressMessage.classList.add('subtle');
|
|
127
|
+
progressToast.appendChild(progressMessage);
|
|
128
|
+
|
|
129
|
+
progressBar = document.createElement('progress');
|
|
130
|
+
progressToast.appendChild(progressBar);
|
|
131
|
+
|
|
132
|
+
list.appendChild(progressToast);
|
|
133
|
+
addEventListener('beforeunload', warnBeforeUnload);
|
|
134
|
+
}
|
|
135
|
+
progressMain.textContent = message;
|
|
136
|
+
progressMessage.textContent = '';
|
|
137
|
+
progressCancelButton.style.display = progressCancel ? '' : 'none';
|
|
138
|
+
progressBar.removeAttribute('value');
|
|
139
|
+
progressBar.max = 1;
|
|
140
|
+
},
|
|
141
|
+
progress(value: number, max: number, message?: any): void {
|
|
142
|
+
if (!progressToast) return;
|
|
143
|
+
progressBar.value = value;
|
|
144
|
+
progressBar.max = max;
|
|
145
|
+
progressMessage.textContent = message == undefined ? '' : String(message);
|
|
146
|
+
},
|
|
147
|
+
done(): void {
|
|
148
|
+
if (!progressToast) return;
|
|
149
|
+
progressToast.remove();
|
|
150
|
+
progressToast = undefined;
|
|
151
|
+
progressCancel = undefined;
|
|
152
|
+
removeEventListener('beforeunload', warnBeforeUnload);
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
|
|
86
156
|
Object.assign(globalThis, { toast, toastStatus });
|
package/locales/en.json
CHANGED
|
@@ -123,6 +123,7 @@
|
|
|
123
123
|
"preferences": "Preferences",
|
|
124
124
|
"sessions": "Sessions",
|
|
125
125
|
"title": "Your Account",
|
|
126
|
+
"toast_username_updated": "Username updated",
|
|
126
127
|
"user_id": "User ID",
|
|
127
128
|
"user_id_hint": "This is your UUID. It can't be changed.",
|
|
128
129
|
"verification_sent": "Verification email sent",
|
package/package.json
CHANGED