add local toasts
This commit is contained in:
parent
07ec0ade92
commit
24f0ae3219
4 changed files with 186 additions and 13 deletions
|
|
@ -63,6 +63,7 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@floating-ui/dom": "^1.7.1",
|
||||||
"better-auth": "^1.2.9"
|
"better-auth": "^1.2.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
|
|
@ -8,6 +8,9 @@ importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@floating-ui/dom':
|
||||||
|
specifier: ^1.7.1
|
||||||
|
version: 1.7.1
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.2.9
|
specifier: ^1.2.9
|
||||||
version: 1.2.9
|
version: 1.2.9
|
||||||
|
|
|
||||||
152
src/lib/builders/local-toasts.svelte.ts
Normal file
152
src/lib/builders/local-toasts.svelte.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import { autoUpdate, computePosition, flip, type Placement } from '@floating-ui/dom';
|
||||||
|
import { Toaster, type ToasterProps } from 'melt/builders';
|
||||||
|
import { createAttachmentKey } from 'svelte/attachments';
|
||||||
|
import type { HTMLAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
type ToastData = {
|
||||||
|
content: string;
|
||||||
|
variant: 'info' | 'danger';
|
||||||
|
};
|
||||||
|
|
||||||
|
const classMap: Record<ToastData['variant'], string> = {
|
||||||
|
info: 'border border-blue-400 bg-gradient-to-b from-blue-500 to-blue-600',
|
||||||
|
|
||||||
|
danger: 'border border-red-400 bg-gradient-to-b from-red-500 to-red-600',
|
||||||
|
};
|
||||||
|
|
||||||
|
export class LocalToasts {
|
||||||
|
id: string;
|
||||||
|
toaster: Toaster<ToastData>;
|
||||||
|
|
||||||
|
constructor(props: ToasterProps & { id: string }) {
|
||||||
|
this.id = props.id;
|
||||||
|
this.toaster = new Toaster<ToastData>(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
get addToast() {
|
||||||
|
return this.toaster.addToast;
|
||||||
|
}
|
||||||
|
|
||||||
|
get trigger() {
|
||||||
|
return {
|
||||||
|
'data-local-toast-trigger': this.id,
|
||||||
|
} as const satisfies HTMLButtonAttributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
get toasts() {
|
||||||
|
const original = this.toaster?.toasts;
|
||||||
|
|
||||||
|
return original.map((toast) => {
|
||||||
|
const attrs = {
|
||||||
|
'data-local-toast': '',
|
||||||
|
'data-variant': toast.data.variant,
|
||||||
|
[createAttachmentKey()]: (node) => {
|
||||||
|
let placement: Placement = $state('top');
|
||||||
|
|
||||||
|
const triggerEl = document.querySelector(`[data-local-toast-trigger=${this.id}]`);
|
||||||
|
if (!triggerEl) return;
|
||||||
|
|
||||||
|
const compute = () =>
|
||||||
|
computePosition(triggerEl, node, {
|
||||||
|
strategy: 'absolute',
|
||||||
|
placement: 'top',
|
||||||
|
middleware: [flip({ fallbackPlacements: ['left'] })],
|
||||||
|
}).then(({ x, y, placement: _placement }) => {
|
||||||
|
placement = _placement;
|
||||||
|
Object.assign(node.style, {
|
||||||
|
left: placement === 'top' ? `${x}px` : `${x - 4}px`,
|
||||||
|
top: placement === 'top' ? `${y - 6}px` : `${y}px`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Animate
|
||||||
|
// Cancel any ongoing animations
|
||||||
|
node.getAnimations().forEach((anim) => anim.cancel());
|
||||||
|
|
||||||
|
// Determine animation direction based on placement
|
||||||
|
let keyframes: Keyframe[] = [];
|
||||||
|
switch (placement) {
|
||||||
|
case 'top':
|
||||||
|
keyframes = [
|
||||||
|
{ opacity: 0, transform: 'translateY(8px)', scale: '0.8' },
|
||||||
|
{ opacity: 1, transform: 'translateY(0)', scale: '1' },
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
case 'left':
|
||||||
|
keyframes = [
|
||||||
|
{ opacity: 0, transform: 'translateX(8px)', scale: '0.8' },
|
||||||
|
{ opacity: 1, transform: 'translateX(0)', scale: '1' },
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
node.animate(keyframes, {
|
||||||
|
duration: 500,
|
||||||
|
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
|
||||||
|
fill: 'forwards',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const reference = node.cloneNode(true) as HTMLElement;
|
||||||
|
node.before(reference);
|
||||||
|
reference.style.visibility = 'hidden';
|
||||||
|
|
||||||
|
const destroyers = [
|
||||||
|
autoUpdate(triggerEl, node, compute),
|
||||||
|
async () => {
|
||||||
|
// clone node
|
||||||
|
const cloned = node.cloneNode(true) as HTMLElement;
|
||||||
|
reference.before(cloned);
|
||||||
|
reference.remove();
|
||||||
|
cloned.getAnimations().forEach((anim) => anim.cancel());
|
||||||
|
|
||||||
|
// Animate out
|
||||||
|
// Cancel any ongoing animations
|
||||||
|
cloned.getAnimations().forEach((anim) => anim.cancel());
|
||||||
|
|
||||||
|
// Determine animation direction based on placement
|
||||||
|
let keyframes: Keyframe[] = [];
|
||||||
|
switch (placement) {
|
||||||
|
case 'top':
|
||||||
|
keyframes = [
|
||||||
|
{ opacity: 1, transform: 'translateY(0)' },
|
||||||
|
{ opacity: 0, transform: 'translateY(-8px)' },
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
case 'left':
|
||||||
|
keyframes = [
|
||||||
|
{ opacity: 1, transform: 'translateX(0)' },
|
||||||
|
{ opacity: 0, transform: 'translateX(-8px)' },
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await cloned.animate(keyframes, {
|
||||||
|
duration: 400,
|
||||||
|
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
|
||||||
|
fill: 'forwards',
|
||||||
|
}).finished;
|
||||||
|
|
||||||
|
cloned.remove();
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return () => destroyers.forEach((d) => d());
|
||||||
|
},
|
||||||
|
style: `
|
||||||
|
/* Float on top of the UI */
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
/* Avoid layout interference */
|
||||||
|
width: max-content;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
`,
|
||||||
|
} as const satisfies HTMLAttributes<HTMLElement>;
|
||||||
|
|
||||||
|
return Object.assign(toast, {
|
||||||
|
class: `${classMap[toast.data.variant]} rounded-full px-2 py-1 text-xs`,
|
||||||
|
attrs,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
import { api } from '$lib/backend/convex/_generated/api';
|
import { api } from '$lib/backend/convex/_generated/api';
|
||||||
import { session } from '$lib/state/session.svelte.js';
|
import { session } from '$lib/state/session.svelte.js';
|
||||||
import type { Provider, ProviderMeta } from '$lib/types';
|
import type { Provider, ProviderMeta } from '$lib/types';
|
||||||
|
import { LocalToasts } from '$lib/builders/local-toasts.svelte';
|
||||||
|
import { ResultAsync } from 'neverthrow';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
provider: Provider;
|
provider: Provider;
|
||||||
|
|
@ -15,6 +17,7 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
let { provider, meta }: Props = $props();
|
let { provider, meta }: Props = $props();
|
||||||
|
const id = $props.id();
|
||||||
|
|
||||||
const keyQuery = useQuery(api.user_keys.get, {
|
const keyQuery = useQuery(api.user_keys.get, {
|
||||||
user_id: session.current?.user.id ?? '',
|
user_id: session.current?.user.id ?? '',
|
||||||
|
|
@ -24,28 +27,36 @@
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
|
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
|
const toasts = new LocalToasts({ id });
|
||||||
|
$inspect(toasts.toasts);
|
||||||
|
|
||||||
async function submit(e: SubmitEvent) {
|
async function submit(e: SubmitEvent) {
|
||||||
loading = true;
|
loading = true;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
|
||||||
const form = e.target as HTMLFormElement;
|
const form = e.target as HTMLFormElement;
|
||||||
const formData = new FormData(form);
|
const formData = new FormData(form);
|
||||||
const key = formData.get('key');
|
const key = formData.get('key');
|
||||||
if (key === null || !session.current?.user.id) return;
|
if (key === null || !session.current?.user.id) return;
|
||||||
|
|
||||||
await client.mutation(api.user_keys.set, {
|
const res = await ResultAsync.fromPromise(
|
||||||
|
client.mutation(api.user_keys.set, {
|
||||||
provider,
|
provider,
|
||||||
user_id: session.current?.user.id ?? '',
|
user_id: session.current?.user.id ?? '',
|
||||||
key: `${key}`,
|
key: `${key}`,
|
||||||
|
}),
|
||||||
|
(e) => e
|
||||||
|
);
|
||||||
|
|
||||||
|
toasts.addToast({
|
||||||
|
data: {
|
||||||
|
content: res.isOk() ? 'Saved' : 'Failed to save',
|
||||||
|
variant: res.isOk() ? 'info' : 'danger',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
|
||||||
// TODO: Setup toast notifications
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
|
|
@ -77,7 +88,13 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<Button {loading} type="submit">Save</Button>
|
<Button {loading} type="submit" {...toasts.trigger}>Save</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
|
{#each toasts.toasts as toast (toast)}
|
||||||
|
<div {...toast.attrs} class={toast.class}>
|
||||||
|
{toast.data.content}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue