@axium/client 0.2.0 → 0.3.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/lib/AccessControl.svelte +37 -0
- package/lib/AccessControlDialog.svelte +12 -0
- package/lib/AppMenu.svelte +34 -0
- package/lib/ClipboardCopy.svelte +42 -0
- package/lib/Dialog.svelte +51 -0
- package/lib/FormDialog.svelte +96 -0
- package/lib/Icon.svelte +18 -0
- package/lib/Login.svelte +36 -0
- package/lib/Logout.svelte +24 -0
- package/lib/NumberBar.svelte +31 -0
- package/lib/Popover.svelte +46 -0
- package/lib/Register.svelte +32 -0
- package/lib/Toast.svelte +35 -0
- package/lib/Upload.svelte +60 -0
- package/lib/UserCard.svelte +48 -0
- package/lib/WithContextMenu.svelte +73 -0
- package/lib/index.ts +16 -0
- package/lib/tsconfig.json +11 -0
- package/package.json +3 -2
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import UserCard from './UserCard.svelte';
|
|
3
|
+
import type { Permission, AccessControl } from '@axium/core/access';
|
|
4
|
+
import { permissionNames } from '@axium/core/access';
|
|
5
|
+
import type { Entries } from 'utilium';
|
|
6
|
+
|
|
7
|
+
const { control, editable }: { control: AccessControl; editable: boolean } = $props();
|
|
8
|
+
|
|
9
|
+
const perm = $derived(permissionNames[control.permission as Permission]);
|
|
10
|
+
|
|
11
|
+
const permEntries = Object.entries(permissionNames) as any as Entries<typeof permissionNames>;
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<div class="AccessControl">
|
|
15
|
+
{#if !control.user}<i>Unknown</i>
|
|
16
|
+
{:else}
|
|
17
|
+
<UserCard user={control.user} />
|
|
18
|
+
{#if editable}
|
|
19
|
+
<input type="hidden" name="userId" value={control.user.id} />
|
|
20
|
+
<select name="permission">
|
|
21
|
+
{#each permEntries as [key, name]}
|
|
22
|
+
<option value={key} selected={key == control.permission}>{name}</option>
|
|
23
|
+
{/each}
|
|
24
|
+
</select>
|
|
25
|
+
{:else}
|
|
26
|
+
<span>{perm}</span>
|
|
27
|
+
{/if}
|
|
28
|
+
{/if}
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<style>
|
|
32
|
+
.AccessControl {
|
|
33
|
+
display: flex;
|
|
34
|
+
gap: 1em;
|
|
35
|
+
padding: 1em 2em;
|
|
36
|
+
}
|
|
37
|
+
</style>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import FormDialog from './FormDialog.svelte';
|
|
3
|
+
import AccessControl from './AccessControl.svelte';
|
|
4
|
+
|
|
5
|
+
let { item, editable } = $props();
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<FormDialog submitText="Save">
|
|
9
|
+
{#each item.acl as control}
|
|
10
|
+
<AccessControl {control} {editable} />
|
|
11
|
+
{/each}
|
|
12
|
+
</FormDialog>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { fetchAPI } from '@axium/client/requests';
|
|
3
|
+
import Icon from './Icon.svelte';
|
|
4
|
+
import Popover from './Popover.svelte';
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<Popover>
|
|
8
|
+
{#snippet toggle()}
|
|
9
|
+
<button style:display="contents">
|
|
10
|
+
<Icon i="grid" --size="1.5em" />
|
|
11
|
+
</button>
|
|
12
|
+
{/snippet}
|
|
13
|
+
|
|
14
|
+
{#await fetchAPI('GET', 'apps')}
|
|
15
|
+
<i>Loading...</i>
|
|
16
|
+
{:then apps}
|
|
17
|
+
{#each apps as app}
|
|
18
|
+
<a class="menu-item" href="/{app.id}">
|
|
19
|
+
{#if app.image}
|
|
20
|
+
<img src={app.image} alt={app.name} width="1em" height="1em" />
|
|
21
|
+
{:else if app.icon}
|
|
22
|
+
<Icon i={app.icon} --size="1.5em" />
|
|
23
|
+
{:else}
|
|
24
|
+
<Icon i="image-circle-xmark" --size="1.5em" />
|
|
25
|
+
{/if}
|
|
26
|
+
<span>{app.name}</span>
|
|
27
|
+
</a>
|
|
28
|
+
{:else}
|
|
29
|
+
<i>No apps available.</i>
|
|
30
|
+
{/each}
|
|
31
|
+
{:catch}
|
|
32
|
+
<i>Couldn't load apps.</i>
|
|
33
|
+
{/await}
|
|
34
|
+
</Popover>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { fade } from 'svelte/transition';
|
|
3
|
+
import { wait } from 'utilium';
|
|
4
|
+
import Icon from './Icon.svelte';
|
|
5
|
+
|
|
6
|
+
const { value, type = 'text/plain' }: { value: BlobPart; type?: string } = $props();
|
|
7
|
+
|
|
8
|
+
let success = $state(false);
|
|
9
|
+
|
|
10
|
+
async function onclick() {
|
|
11
|
+
const blob = new Blob([value], { type });
|
|
12
|
+
const item = new ClipboardItem({ [type]: blob });
|
|
13
|
+
await navigator.clipboard.write([item]);
|
|
14
|
+
success = true;
|
|
15
|
+
await wait(3000);
|
|
16
|
+
success = false;
|
|
17
|
+
}
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<button {onclick}>
|
|
21
|
+
{#if success}
|
|
22
|
+
<span transition:fade><Icon i="check" /></span>
|
|
23
|
+
{:else}
|
|
24
|
+
<span transition:fade><Icon i="copy" /></span>
|
|
25
|
+
{/if}
|
|
26
|
+
</button>
|
|
27
|
+
|
|
28
|
+
<style>
|
|
29
|
+
button {
|
|
30
|
+
position: relative;
|
|
31
|
+
display: inline-block;
|
|
32
|
+
width: 1em;
|
|
33
|
+
height: 1em;
|
|
34
|
+
border: none;
|
|
35
|
+
background: transparent;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
span {
|
|
39
|
+
position: absolute;
|
|
40
|
+
inset: 0;
|
|
41
|
+
}
|
|
42
|
+
</style>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
let { children, dialog = $bindable(), ...rest } = $props();
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<dialog bind:this={dialog} {...rest}>
|
|
6
|
+
{@render children()}
|
|
7
|
+
</dialog>
|
|
8
|
+
|
|
9
|
+
<!-- svelte-ignore css_unused_selector -->
|
|
10
|
+
<style>
|
|
11
|
+
dialog {
|
|
12
|
+
border-radius: 1em;
|
|
13
|
+
background: var(--bg-menu);
|
|
14
|
+
border: 1px solid #8888;
|
|
15
|
+
padding: 1em;
|
|
16
|
+
|
|
17
|
+
form {
|
|
18
|
+
display: contents;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
dialog::backdrop {
|
|
23
|
+
background: #0003;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
dialog[open] {
|
|
27
|
+
animation: zoom 0.25s cubic-bezier(0.35, 1.55, 0.65, 1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@keyframes zoom {
|
|
31
|
+
from {
|
|
32
|
+
transform: scale(0.95);
|
|
33
|
+
}
|
|
34
|
+
to {
|
|
35
|
+
transform: scale(1);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
dialog[open]::backdrop {
|
|
40
|
+
animation: fade 0.25s ease-out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@keyframes fade {
|
|
44
|
+
from {
|
|
45
|
+
opacity: 0;
|
|
46
|
+
}
|
|
47
|
+
to {
|
|
48
|
+
opacity: 1;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
</style>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Dialog from './Dialog.svelte';
|
|
3
|
+
|
|
4
|
+
function resolveRedirectAfter() {
|
|
5
|
+
const url = new URL(location.href);
|
|
6
|
+
const maybe = url.searchParams.get('after');
|
|
7
|
+
if (!maybe || maybe == url.pathname) return '/';
|
|
8
|
+
for (const prefix of ['/api/']) if (maybe.startsWith(prefix)) return '/';
|
|
9
|
+
return maybe || '/';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let {
|
|
13
|
+
children,
|
|
14
|
+
dialog = $bindable(),
|
|
15
|
+
submitText = 'Submit',
|
|
16
|
+
cancel = () => {},
|
|
17
|
+
submit = (data: object): Promise<any> => Promise.resolve(),
|
|
18
|
+
pageMode = false,
|
|
19
|
+
submitDanger = false,
|
|
20
|
+
header,
|
|
21
|
+
footer,
|
|
22
|
+
...rest
|
|
23
|
+
}: {
|
|
24
|
+
children(): any;
|
|
25
|
+
dialog?: HTMLDialogElement;
|
|
26
|
+
/** Change the text displayed for the submit button */
|
|
27
|
+
submitText?: string;
|
|
28
|
+
/** Basically a callback for when the dialog is canceled */
|
|
29
|
+
cancel?(): unknown;
|
|
30
|
+
/** Called on submission, this should do the actual submission */
|
|
31
|
+
submit?(data: Record<string, FormDataEntryValue>): Promise<any>;
|
|
32
|
+
/** Whether to display the dialog as a full-page form */
|
|
33
|
+
pageMode?: boolean;
|
|
34
|
+
submitDanger?: boolean;
|
|
35
|
+
header?(): any;
|
|
36
|
+
footer?(): any;
|
|
37
|
+
} = $props();
|
|
38
|
+
|
|
39
|
+
let error = $state<string>();
|
|
40
|
+
|
|
41
|
+
$effect(() => {
|
|
42
|
+
if (pageMode) dialog!.showModal();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function onclose(e: MouseEvent) {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
cancel();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function onsubmit(e: SubmitEvent & { currentTarget: HTMLFormElement }) {
|
|
51
|
+
e.preventDefault();
|
|
52
|
+
const data = Object.fromEntries(new FormData(e.currentTarget));
|
|
53
|
+
submit(data)
|
|
54
|
+
.then(result => {
|
|
55
|
+
if (pageMode) window.location.href = resolveRedirectAfter();
|
|
56
|
+
else dialog!.close();
|
|
57
|
+
})
|
|
58
|
+
.catch((e: any) => {
|
|
59
|
+
if (!e) error = 'An unknown error occurred';
|
|
60
|
+
else if (typeof e == 'object' && 'message' in e) error = e.message;
|
|
61
|
+
else error = e;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
{#snippet submitButton()}
|
|
67
|
+
<button type="submit" class={['submit', submitDanger && 'danger']}>{submitText}</button>
|
|
68
|
+
{/snippet}
|
|
69
|
+
|
|
70
|
+
<Dialog bind:dialog {onclose} {...rest}>
|
|
71
|
+
{@render header?.()}
|
|
72
|
+
<form {onsubmit} class="main" method="dialog">
|
|
73
|
+
{#if error}
|
|
74
|
+
<div class="error">{error}</div>
|
|
75
|
+
{/if}
|
|
76
|
+
{@render children()}
|
|
77
|
+
{#if pageMode}
|
|
78
|
+
{@render submitButton()}
|
|
79
|
+
{:else}
|
|
80
|
+
<div class="actions">
|
|
81
|
+
<button type="button" onclick={() => dialog!.close()}>Cancel</button>
|
|
82
|
+
{@render submitButton()}
|
|
83
|
+
</div>
|
|
84
|
+
{/if}
|
|
85
|
+
</form>
|
|
86
|
+
{@render footer?.()}
|
|
87
|
+
</Dialog>
|
|
88
|
+
|
|
89
|
+
<style>
|
|
90
|
+
.actions {
|
|
91
|
+
display: flex;
|
|
92
|
+
gap: 1em;
|
|
93
|
+
flex-direction: row;
|
|
94
|
+
justify-content: space-between;
|
|
95
|
+
}
|
|
96
|
+
</style>
|
package/lib/Icon.svelte
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
const { i, ...rest } = $props();
|
|
3
|
+
const [style, id] = $derived(i.includes('/') ? i.split('/') : ['solid', i]);
|
|
4
|
+
const href = $derived(`/icons/${style}.svg#${id}`);
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="1em" height="1em" {...rest}>
|
|
8
|
+
<use {href} />
|
|
9
|
+
</svg>
|
|
10
|
+
|
|
11
|
+
<style>
|
|
12
|
+
svg {
|
|
13
|
+
width: var(--size, 1em);
|
|
14
|
+
height: var(--size, 1em);
|
|
15
|
+
display: inline-block;
|
|
16
|
+
fill: var(--fill, hsl(var(--hue) 0 var(--fg-light)));
|
|
17
|
+
}
|
|
18
|
+
</style>
|
package/lib/Login.svelte
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { loginByEmail } from '@axium/client/user';
|
|
3
|
+
import FormDialog from './FormDialog.svelte';
|
|
4
|
+
|
|
5
|
+
let { dialog = $bindable(), fullPage = false }: { dialog?: HTMLDialogElement; fullPage?: boolean } = $props();
|
|
6
|
+
|
|
7
|
+
function submit(data: { email: string }) {
|
|
8
|
+
if (typeof data.email != 'string') {
|
|
9
|
+
throw 'Tried to upload a file for an email. Huh?!';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return loginByEmail(data.email);
|
|
13
|
+
}
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<FormDialog bind:dialog submitText="Login" {submit} pageMode={fullPage}>
|
|
17
|
+
<div>
|
|
18
|
+
<label for="email">Email</label>
|
|
19
|
+
<input name="email" type="email" required />
|
|
20
|
+
</div>
|
|
21
|
+
{#snippet footer()}
|
|
22
|
+
{#if fullPage}
|
|
23
|
+
<div class="footer">
|
|
24
|
+
<a href="/register">Register instead</a>
|
|
25
|
+
</div>
|
|
26
|
+
{/if}
|
|
27
|
+
{/snippet}
|
|
28
|
+
</FormDialog>
|
|
29
|
+
|
|
30
|
+
<style>
|
|
31
|
+
.footer {
|
|
32
|
+
margin-bottom: 1em;
|
|
33
|
+
text-align: center;
|
|
34
|
+
background: none;
|
|
35
|
+
}
|
|
36
|
+
</style>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { logoutCurrentSession } from '@axium/client/user';
|
|
3
|
+
import FormDialog from './FormDialog.svelte';
|
|
4
|
+
|
|
5
|
+
let { dialog = $bindable(), fullPage = false }: { dialog?: HTMLDialogElement; fullPage?: boolean } = $props();
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<FormDialog
|
|
9
|
+
pageMode={fullPage}
|
|
10
|
+
bind:dialog
|
|
11
|
+
submitText="Log Out"
|
|
12
|
+
submit={() => logoutCurrentSession().then(() => (window.location.href = '/'))}
|
|
13
|
+
>
|
|
14
|
+
<p>Are you sure you want to log out?</p>
|
|
15
|
+
{#if fullPage}
|
|
16
|
+
<button
|
|
17
|
+
onclick={e => {
|
|
18
|
+
e.preventDefault();
|
|
19
|
+
dialog!.close();
|
|
20
|
+
history.back();
|
|
21
|
+
}}>Take me back</button
|
|
22
|
+
>
|
|
23
|
+
{/if}
|
|
24
|
+
</FormDialog>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
const { min = 0, max, value, text } = $props();
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<div class="Bar">
|
|
6
|
+
<div class="fill" style="width: {((value - min) / (max - min)) * 100}%"></div>
|
|
7
|
+
{#if text}<span class="text">{text}</span>{/if}
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<style>
|
|
11
|
+
.Bar {
|
|
12
|
+
position: relative;
|
|
13
|
+
border-radius: calc(var(--height, 2em) / 2);
|
|
14
|
+
height: var(--height, 2em);
|
|
15
|
+
background-color: hsl(0 0 calc(var(--bg-light) + (var(--light-step) * 2)));
|
|
16
|
+
overflow: hidden;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.text {
|
|
20
|
+
position: absolute;
|
|
21
|
+
inset: calc(calc(var(--height, 2em) - 1em) / 2) 1em;
|
|
22
|
+
width: fit-content;
|
|
23
|
+
line-height: 1;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.fill {
|
|
27
|
+
border-radius: calc(var(--height, 2em) / 2);
|
|
28
|
+
height: 100%;
|
|
29
|
+
background-color: var(--fill, #465);
|
|
30
|
+
}
|
|
31
|
+
</style>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Icon from './Icon.svelte';
|
|
3
|
+
const { children, toggle }: { children(): any; toggle?(): any } = $props();
|
|
4
|
+
|
|
5
|
+
let popover = $state<HTMLDivElement>();
|
|
6
|
+
|
|
7
|
+
function onclick(e: MouseEvent) {
|
|
8
|
+
e.stopPropagation();
|
|
9
|
+
popover?.togglePopover();
|
|
10
|
+
}
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<div onclick={e => e.stopPropagation()}>
|
|
14
|
+
<div style:display="contents" {onclick}>
|
|
15
|
+
{#if toggle}
|
|
16
|
+
{@render toggle()}
|
|
17
|
+
{:else}
|
|
18
|
+
<span class="popover-toggle">
|
|
19
|
+
<Icon i="ellipsis" />
|
|
20
|
+
</span>
|
|
21
|
+
{/if}
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div popover bind:this={popover}>
|
|
25
|
+
{@render children()}
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<style>
|
|
30
|
+
.popover-toggle:hover {
|
|
31
|
+
cursor: pointer;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
[popover] :global(.menu-item) {
|
|
35
|
+
display: inline-flex;
|
|
36
|
+
align-items: center;
|
|
37
|
+
padding: 0.5em 0.75em;
|
|
38
|
+
gap: 1em;
|
|
39
|
+
border-radius: 0.5em;
|
|
40
|
+
|
|
41
|
+
&:hover {
|
|
42
|
+
background-color: var(--bg-strong);
|
|
43
|
+
cursor: pointer;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
</style>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { register } from '@axium/client/user';
|
|
3
|
+
import FormDialog from './FormDialog.svelte';
|
|
4
|
+
|
|
5
|
+
let { dialog = $bindable(), fullPage = false }: { dialog?: HTMLDialogElement; fullPage?: boolean } = $props();
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<FormDialog bind:dialog submitText="Register" submit={register} pageMode={fullPage}>
|
|
9
|
+
<div>
|
|
10
|
+
<label for="name">Display Name</label>
|
|
11
|
+
<input name="name" type="text" required />
|
|
12
|
+
</div>
|
|
13
|
+
<div>
|
|
14
|
+
<label for="email">Email</label>
|
|
15
|
+
<input name="email" type="email" required />
|
|
16
|
+
</div>
|
|
17
|
+
{#snippet footer()}
|
|
18
|
+
{#if fullPage}
|
|
19
|
+
<div class="footer">
|
|
20
|
+
<a href="/login">Login instead</a>
|
|
21
|
+
</div>
|
|
22
|
+
{/if}
|
|
23
|
+
{/snippet}
|
|
24
|
+
</FormDialog>
|
|
25
|
+
|
|
26
|
+
<style>
|
|
27
|
+
.footer {
|
|
28
|
+
margin-bottom: 1em;
|
|
29
|
+
text-align: center;
|
|
30
|
+
background: none;
|
|
31
|
+
}
|
|
32
|
+
</style>
|
package/lib/Toast.svelte
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { fade } from 'svelte/transition';
|
|
3
|
+
|
|
4
|
+
const { enabled, children, delay = 5000, duration = 1000, ...rest } = $props();
|
|
5
|
+
|
|
6
|
+
let hiding = $state(false);
|
|
7
|
+
|
|
8
|
+
const show = $derived(enabled && !hiding);
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
{#if show}
|
|
12
|
+
<div
|
|
13
|
+
class="Toast"
|
|
14
|
+
in:fade|global={{ duration }}
|
|
15
|
+
onintroend={() => (hiding = true)}
|
|
16
|
+
out:fade|global={{ delay, duration }}
|
|
17
|
+
onoutroend={() => (hiding = false)}
|
|
18
|
+
{...rest}
|
|
19
|
+
>
|
|
20
|
+
{@render children()}
|
|
21
|
+
</div>
|
|
22
|
+
{/if}
|
|
23
|
+
|
|
24
|
+
<style>
|
|
25
|
+
.Toast {
|
|
26
|
+
position: fixed;
|
|
27
|
+
bottom: 1em;
|
|
28
|
+
left: calc(50% - 10em);
|
|
29
|
+
right: calc(50% - 10em);
|
|
30
|
+
width: 20em;
|
|
31
|
+
padding: 0.5em 1em;
|
|
32
|
+
border-radius: 1em;
|
|
33
|
+
opacity: 0.5;
|
|
34
|
+
}
|
|
35
|
+
</style>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { forMime } from '@axium/core/icons';
|
|
3
|
+
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
4
|
+
import Icon from './Icon.svelte';
|
|
5
|
+
|
|
6
|
+
let {
|
|
7
|
+
name = 'files',
|
|
8
|
+
input = $bindable(),
|
|
9
|
+
files = $bindable(),
|
|
10
|
+
...rest
|
|
11
|
+
}: HTMLInputAttributes & { input?: HTMLInputElement } = $props();
|
|
12
|
+
|
|
13
|
+
const id = $props.id();
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<div>
|
|
17
|
+
<label for={id} class={[files?.length && 'file']}>
|
|
18
|
+
{#each files! as file}
|
|
19
|
+
<Icon i={forMime(file.type)} />
|
|
20
|
+
<span>{file.name}</span>
|
|
21
|
+
<button
|
|
22
|
+
onclick={e => {
|
|
23
|
+
e.preventDefault();
|
|
24
|
+
const dt = new DataTransfer();
|
|
25
|
+
for (let f of files!) if (file !== f) dt.items.add(f);
|
|
26
|
+
input!.files = files = dt.files;
|
|
27
|
+
}}
|
|
28
|
+
style:display="contents"
|
|
29
|
+
>
|
|
30
|
+
<Icon i="trash" />
|
|
31
|
+
</button>
|
|
32
|
+
{:else}
|
|
33
|
+
<Icon i="upload" /> Upload
|
|
34
|
+
{/each}
|
|
35
|
+
</label>
|
|
36
|
+
|
|
37
|
+
<input bind:this={input} bind:files {name} {id} type="file" {...rest} />
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<style>
|
|
41
|
+
input {
|
|
42
|
+
display: none;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
label {
|
|
46
|
+
padding: 0.5em 1em;
|
|
47
|
+
border: 1px solid hsl(0 0 calc(var(--fg-light) + var(--light-step)) / 80);
|
|
48
|
+
cursor: pointer;
|
|
49
|
+
display: flex;
|
|
50
|
+
align-items: center;
|
|
51
|
+
gap: 0.5em;
|
|
52
|
+
border-radius: 0.5em;
|
|
53
|
+
width: 20em;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
label.file {
|
|
57
|
+
display: grid;
|
|
58
|
+
grid-template-columns: 2em 1fr 2em;
|
|
59
|
+
}
|
|
60
|
+
</style>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { User } from '@axium/core/user';
|
|
3
|
+
import { getUserImage } from '@axium/core';
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
user,
|
|
7
|
+
compact = false,
|
|
8
|
+
self = false,
|
|
9
|
+
href = `/users/${user.id}`,
|
|
10
|
+
you = false,
|
|
11
|
+
}: {
|
|
12
|
+
user: Partial<User>;
|
|
13
|
+
/** If true, don't show the picture */
|
|
14
|
+
compact?: boolean;
|
|
15
|
+
/** Whether the user is viewing their own profile */
|
|
16
|
+
self?: boolean;
|
|
17
|
+
/** The URL to link to */
|
|
18
|
+
href?: string;
|
|
19
|
+
/** Whether to display a "You" label if `self` */
|
|
20
|
+
you?: boolean;
|
|
21
|
+
} = $props();
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<a class={['User', self && 'self']} {href}>
|
|
25
|
+
{#if !compact}
|
|
26
|
+
<img src={getUserImage(user)} alt={user.name} />
|
|
27
|
+
{/if}
|
|
28
|
+
{user.name}
|
|
29
|
+
{#if self && you}
|
|
30
|
+
<span class="subtle">(You)</span>
|
|
31
|
+
{/if}
|
|
32
|
+
</a>
|
|
33
|
+
|
|
34
|
+
<style>
|
|
35
|
+
.User {
|
|
36
|
+
cursor: pointer;
|
|
37
|
+
width: max-content;
|
|
38
|
+
height: max-content;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
img {
|
|
42
|
+
width: 2em;
|
|
43
|
+
height: 2em;
|
|
44
|
+
border-radius: 50%;
|
|
45
|
+
vertical-align: middle;
|
|
46
|
+
margin-right: 0.5em;
|
|
47
|
+
}
|
|
48
|
+
</style>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
import Icon from './Icon.svelte';
|
|
4
|
+
|
|
5
|
+
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
6
|
+
children(): any;
|
|
7
|
+
menu(
|
|
8
|
+
/**
|
|
9
|
+
* Shortcut to quickly create a generic action in the context menu.
|
|
10
|
+
*/
|
|
11
|
+
action: (icon: string, text: string, action: (event: MouseEvent) => void) => any
|
|
12
|
+
): any;
|
|
13
|
+
actions: Record<string, () => void>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let { children, menu, actions, ...rest }: Props = $props();
|
|
17
|
+
|
|
18
|
+
let popover = $state<HTMLDivElement>();
|
|
19
|
+
|
|
20
|
+
function oncontextmenu(e: MouseEvent) {
|
|
21
|
+
e.preventDefault();
|
|
22
|
+
e.stopPropagation();
|
|
23
|
+
popover!.togglePopover();
|
|
24
|
+
_forcePopover = true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let _forcePopover = false;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Workaround for https://github.com/whatwg/html/issues/10905
|
|
31
|
+
* @todo Remove when the problem is fixed.
|
|
32
|
+
*/
|
|
33
|
+
function onpointerup(e: PointerEvent) {
|
|
34
|
+
if (!_forcePopover) return;
|
|
35
|
+
e.stopPropagation();
|
|
36
|
+
e.preventDefault();
|
|
37
|
+
popover!.togglePopover();
|
|
38
|
+
_forcePopover = false;
|
|
39
|
+
}
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
{#snippet action(i: string, text: string, action: (event: MouseEvent) => void)}
|
|
43
|
+
<div
|
|
44
|
+
onclick={e => {
|
|
45
|
+
e.stopPropagation();
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
action(e);
|
|
48
|
+
}}
|
|
49
|
+
class="action"
|
|
50
|
+
>
|
|
51
|
+
{#if i}<Icon {i} --size="14px" />{/if}
|
|
52
|
+
{text}
|
|
53
|
+
</div>
|
|
54
|
+
{/snippet}
|
|
55
|
+
|
|
56
|
+
<div data-axium-context-menu {oncontextmenu} {onpointerup} {...rest}>
|
|
57
|
+
{@render children()}
|
|
58
|
+
<div popover bind:this={popover}>
|
|
59
|
+
{@render menu(action)}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<style>
|
|
64
|
+
[data-axium-context-menu] {
|
|
65
|
+
display: contents;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
div.action:hover {
|
|
69
|
+
cursor: pointer;
|
|
70
|
+
background-color: var(--bg-strong);
|
|
71
|
+
border-radius: 0.25em;
|
|
72
|
+
}
|
|
73
|
+
</style>
|
package/lib/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { default as AccessControl } from './AccessControl.svelte';
|
|
2
|
+
export { default as AccessControlDialog } from './AccessControlDialog.svelte';
|
|
3
|
+
export { default as AppMenu } from './AppMenu.svelte';
|
|
4
|
+
export { default as ClipboardCopy } from './ClipboardCopy.svelte';
|
|
5
|
+
export { default as Dialog } from './Dialog.svelte';
|
|
6
|
+
export { default as FormDialog } from './FormDialog.svelte';
|
|
7
|
+
export { default as Icon } from './Icon.svelte';
|
|
8
|
+
export { default as Login } from './Login.svelte';
|
|
9
|
+
export { default as Logout } from './Logout.svelte';
|
|
10
|
+
export { default as NumberBar } from './NumberBar.svelte';
|
|
11
|
+
export { default as Popover } from './Popover.svelte';
|
|
12
|
+
export { default as Register } from './Register.svelte';
|
|
13
|
+
export { default as Toast } from './Toast.svelte';
|
|
14
|
+
export { default as Upload } from './Upload.svelte';
|
|
15
|
+
export { default as UserCard } from './UserCard.svelte';
|
|
16
|
+
export { default as WithContextMenu } from './WithContextMenu.svelte';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axium/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"author": "James Prevett <jp@jamespre.dev>",
|
|
5
5
|
"funding": {
|
|
6
6
|
"type": "individual",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"main": "dist/index.js",
|
|
20
20
|
"types": "dist/index.d.ts",
|
|
21
21
|
"files": [
|
|
22
|
-
"dist"
|
|
22
|
+
"dist",
|
|
23
|
+
"lib"
|
|
23
24
|
],
|
|
24
25
|
"exports": {
|
|
25
26
|
".": "./dist/index.js",
|