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": {
|
||||
"@floating-ui/dom": "^1.7.1",
|
||||
"better-auth": "^1.2.9"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
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 { 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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue