add local toasts

This commit is contained in:
Thomas G. Lopes 2025-06-14 18:20:13 +01:00
parent 07ec0ade92
commit 24f0ae3219
4 changed files with 186 additions and 13 deletions

View file

@ -63,6 +63,7 @@
]
},
"dependencies": {
"@floating-ui/dom": "^1.7.1",
"better-auth": "^1.2.9"
}
}

3
pnpm-lock.yaml generated
View file

@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@floating-ui/dom':
specifier: ^1.7.1
version: 1.7.1
better-auth:
specifier: ^1.2.9
version: 1.2.9

View 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,
});
});
}
}

View file

@ -8,6 +8,8 @@
import { api } from '$lib/backend/convex/_generated/api';
import { session } from '$lib/state/session.svelte.js';
import type { Provider, ProviderMeta } from '$lib/types';
import { LocalToasts } from '$lib/builders/local-toasts.svelte';
import { ResultAsync } from 'neverthrow';
type Props = {
provider: Provider;
@ -15,6 +17,7 @@
};
let { provider, meta }: Props = $props();
const id = $props.id();
const keyQuery = useQuery(api.user_keys.get, {
user_id: session.current?.user.id ?? '',
@ -24,27 +27,35 @@
const client = useConvexClient();
let loading = $state(false);
const toasts = new LocalToasts({ id });
$inspect(toasts.toasts);
async function submit(e: SubmitEvent) {
loading = true;
e.preventDefault();
try {
const form = e.target as HTMLFormElement;
const formData = new FormData(form);
const key = formData.get('key');
if (key === null || !session.current?.user.id) return;
const form = e.target as HTMLFormElement;
const formData = new FormData(form);
const key = formData.get('key');
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,
user_id: session.current?.user.id ?? '',
key: `${key}`,
});
} catch {
// TODO: Setup toast notifications
} finally {
loading = false;
}
}),
(e) => e
);
toasts.addToast({
data: {
content: res.isOk() ? 'Saved' : 'Failed to save',
variant: res.isOk() ? 'info' : 'danger',
},
});
loading = false;
}
</script>
@ -77,7 +88,13 @@
</span>
</div>
<div class="flex justify-end">
<Button {loading} type="submit">Save</Button>
<Button {loading} type="submit" {...toasts.trigger}>Save</Button>
</div>
</Card.Content>
</Card.Root>
{#each toasts.toasts as toast (toast)}
<div {...toast.attrs} class={toast.class}>
{toast.data.content}
</div>
{/each}