Problem: the hexagonal refactor churns the backend tree for nine more phases; the UI delivery stack (web/ SPA, cmd/desktop Wails wrapper, compose/web image) must move to its own repo first so doc/layout rewrites land once on a backend-only tree. Change: - New repo git.hubris.network/dtoro/oikos-web (v0.33.0): web/, desktop/ (updateURL repointed to oikos-web releases), compose/, own CI (web + desktop jobs), own deploy script (CI-green gate, TOCTOU guard, version-tagged images, prune-to-3), own webhook receiver on :9798 + launchd unit, own compose project publishing the same 8091:80. - Cutover executed on mac-mini in order: oikos stack's web service stopped+removed, oikos-web project brought up on 8091; outer Caddy untouched (targets the published port) — serving + Authentik flow + /wails 404 quirk verified post-cutover. - Stripped from oikos: web/, cmd/desktop/, compose/web/, desktop CI workflow, ci.yml web job, Makefile ui/desktop/desktop-package/install targets, the compose web service, oikos-web from deploy.sh's fallback prune list; wails + go-keyring dropped from go.mod, vendor synced. - README / CONTRIBUTING / AGENTS.md / .agents dev+operations docs now point at the new repo; mbse + mascot design docs carry a path note. Risk: production SPA serving depends on the new pipeline now; rollback is versioned-image re-up of the old web service from a pre-split checkout (port 8091). Desktop builds installed before the split still check dtoro/oikos releases — one manual reinstall, noted in the oikos-web release notes. Verification: go vet, make test (race), make generate-check, golangci (no new findings; baseline down 400→365); post-cutover curls — localhost:8091 200, /wails/runtime.js 404, outer Caddy 302 Authentik.
502 lines
16 KiB
Svelte
502 lines
16 KiB
Svelte
<script lang="ts" module>"use strict";
|
|
// Default lifetime of a toasts (in ms)
|
|
const TOAST_LIFETIME = 4000;
|
|
// Default gap between toasts
|
|
const GAP = 14;
|
|
// Threshold to dismiss a toast
|
|
const SWIPE_THRESHOLD = 45;
|
|
// Equal to exit animation duration
|
|
const TIME_BEFORE_UNMOUNT = 200;
|
|
const SCALE_MULTIPLIER = 0.05;
|
|
const DEFAULT_TOAST_CLASSES = {
|
|
toast: '',
|
|
title: '',
|
|
description: '',
|
|
loader: '',
|
|
closeButton: '',
|
|
cancelButton: '',
|
|
actionButton: '',
|
|
action: '',
|
|
warning: '',
|
|
error: '',
|
|
success: '',
|
|
default: '',
|
|
info: '',
|
|
loading: ''
|
|
};
|
|
function getDefaultSwipeDirections(position) {
|
|
const [y, x] = position.split('-');
|
|
const directions = [];
|
|
if (y) {
|
|
directions.push(y);
|
|
}
|
|
if (x) {
|
|
directions.push(x);
|
|
}
|
|
return directions;
|
|
}
|
|
function getDampening(delta) {
|
|
const factor = Math.abs(delta) / 20;
|
|
return 1 / (1.5 + factor);
|
|
}
|
|
</script>
|
|
|
|
<script lang="ts">import { onMount, untrack } from 'svelte';
|
|
import { isAction } from './types.js';
|
|
import { toastState } from './toast-state.svelte.js';
|
|
import { cn } from './internal/helpers.js';
|
|
import { useDocumentHidden } from './internal/use-document-hidden.svelte.js';
|
|
import Loader from './Loader.svelte';
|
|
let { toast, index, expanded, invert: invertFromToaster, position, visibleToasts, expandByDefault, closeButton: closeButtonFromToaster, interacting, cancelButtonStyle = '', actionButtonStyle = '', duration: durationFromToaster, descriptionClass = '', classes: classesProp, unstyled = false, loadingIcon, successIcon, errorIcon, warningIcon, closeIcon, infoIcon, defaultRichColors = false, swipeDirections: swipeDirectionsProp, closeButtonAriaLabel, pauseWhenPageIsHidden, ...restProps } = $props();
|
|
const defaultClasses = { ...DEFAULT_TOAST_CLASSES };
|
|
let mounted = $state(false);
|
|
let removed = $state(false);
|
|
let swiping = $state(false);
|
|
let swipeOut = $state(false);
|
|
let isSwiped = $state(false);
|
|
let offsetBeforeRemove = $state(0);
|
|
let initialHeight = $state(0);
|
|
let remainingTime = toast.duration || durationFromToaster || TOAST_LIFETIME;
|
|
let dragStartTime = $state(null);
|
|
let toastRef = $state();
|
|
let swipeDirection = $state(null);
|
|
let swipeOutDirection = $state(null);
|
|
const isFront = $derived(index === 0);
|
|
const isVisible = $derived(index + 1 <= visibleToasts);
|
|
const toastType = $derived(toast.type);
|
|
const dismissible = $derived(toast.dismissible !== undefined
|
|
? toast.dismissible !== false
|
|
: toast.dismissable !== false);
|
|
const toastClass = $derived(toast.class || '');
|
|
const toastDescriptionClass = $derived(toast.descriptionClass || '');
|
|
// height index is used to calculate the offset as it gets updated before the toast array, which means we can calculate the new layout faster.
|
|
const heightIndex = $derived(toastState.heights.findIndex((height) => height.toastId === toast.id) ||
|
|
0);
|
|
const closeButton = $derived(toast.closeButton ?? closeButtonFromToaster);
|
|
const duration = $derived(toast.duration ?? durationFromToaster ?? TOAST_LIFETIME);
|
|
let pointerStart = null;
|
|
const coords = $derived(position.split('-'));
|
|
const toastsHeightBefore = $derived(toastState.heights.reduce((prev, curr, reducerIndex) => {
|
|
if (reducerIndex >= heightIndex)
|
|
return prev;
|
|
return prev + curr.height;
|
|
}, 0));
|
|
const isDocumentHidden = useDocumentHidden();
|
|
const invert = $derived(toast.invert || invertFromToaster);
|
|
const disabled = $derived(toastType === 'loading');
|
|
const classes = $derived({ ...defaultClasses, ...classesProp });
|
|
const toastTitle = $derived(toast.title);
|
|
const toastDescription = $derived(toast.description);
|
|
let closeTimerStartTime = $state(0);
|
|
let lastCloseTimerStartTime = $state(0);
|
|
const offset = $derived(Math.round(heightIndex * GAP + toastsHeightBefore));
|
|
$effect(() => {
|
|
toastTitle;
|
|
toastDescription;
|
|
let scale;
|
|
if (expanded || expandByDefault) {
|
|
scale = 1;
|
|
}
|
|
else {
|
|
scale = 1 - index * SCALE_MULTIPLIER;
|
|
}
|
|
const toastEl = untrack(() => toastRef);
|
|
if (toastEl === undefined)
|
|
return;
|
|
toastEl.style.setProperty('height', 'auto');
|
|
const offsetHeight = toastEl.offsetHeight;
|
|
const rectHeight = toastEl.getBoundingClientRect().height;
|
|
const scaledRectHeight = Math.round((rectHeight / scale + Number.EPSILON) & 100) / 100;
|
|
toastEl.style.removeProperty('height');
|
|
let finalHeight;
|
|
if (Math.abs(scaledRectHeight - offsetHeight) < 1) {
|
|
// use scaledRectHeight as it's more precise
|
|
finalHeight = scaledRectHeight;
|
|
}
|
|
else {
|
|
// toast was transitioning its scale, so scaledRectHeight isn't accurate
|
|
finalHeight = offsetHeight;
|
|
}
|
|
initialHeight = finalHeight;
|
|
toastState.setHeight({ toastId: toast.id, height: finalHeight });
|
|
});
|
|
function deleteToast() {
|
|
removed = true;
|
|
// save the offset for the exit swipe animation
|
|
offsetBeforeRemove = offset;
|
|
toastState.removeHeight(toast.id);
|
|
setTimeout(() => {
|
|
toastState.remove(toast.id);
|
|
}, TIME_BEFORE_UNMOUNT);
|
|
}
|
|
let timeoutId;
|
|
const isPromiseLoadingOrInfiniteDuration = $derived((toast.promise && toastType === 'loading') ||
|
|
toast.duration === Number.POSITIVE_INFINITY);
|
|
function startTimer() {
|
|
closeTimerStartTime = new Date().getTime();
|
|
// let the toast know it has started
|
|
timeoutId = setTimeout(() => {
|
|
toast.onAutoClose?.(toast);
|
|
deleteToast();
|
|
}, remainingTime);
|
|
}
|
|
function pauseTimer() {
|
|
if (lastCloseTimerStartTime < closeTimerStartTime) {
|
|
// get the elapsed time since the timer started
|
|
const elapsedTime = new Date().getTime() - closeTimerStartTime;
|
|
remainingTime = remainingTime - elapsedTime;
|
|
}
|
|
lastCloseTimerStartTime = new Date().getTime();
|
|
}
|
|
$effect(() => {
|
|
if (toast.updated) {
|
|
// if the toast has been updated after the initial render,
|
|
// we want to reset the timer and set the remaining time to the
|
|
// new duration
|
|
clearTimeout(timeoutId);
|
|
remainingTime = duration;
|
|
if (!isPromiseLoadingOrInfiniteDuration) {
|
|
startTimer();
|
|
}
|
|
}
|
|
});
|
|
$effect(() => {
|
|
if (!isPromiseLoadingOrInfiniteDuration) {
|
|
if (expanded || interacting || (pauseWhenPageIsHidden && isDocumentHidden.current)) {
|
|
pauseTimer();
|
|
}
|
|
else {
|
|
startTimer();
|
|
}
|
|
}
|
|
return () => clearTimeout(timeoutId);
|
|
});
|
|
onMount(() => {
|
|
mounted = true;
|
|
const height = toastRef?.getBoundingClientRect().height;
|
|
initialHeight = height;
|
|
toastState.setHeight({ toastId: toast.id, height });
|
|
return () => {
|
|
toastState.removeHeight(toast.id);
|
|
};
|
|
});
|
|
$effect(() => {
|
|
if (toast.delete) {
|
|
untrack(() => {
|
|
deleteToast();
|
|
toast.onDismiss?.(toast);
|
|
});
|
|
}
|
|
});
|
|
const handlePointerDown = (event) => {
|
|
if (disabled)
|
|
return;
|
|
offsetBeforeRemove = offset;
|
|
const target = event.target;
|
|
// ensure we maintain correct pointer capture even when going outside of the toast (e.g. when swiping)
|
|
target.setPointerCapture(event.pointerId);
|
|
if (target.tagName === 'BUTTON')
|
|
return;
|
|
swiping = true;
|
|
pointerStart = { x: event.clientX, y: event.clientY };
|
|
};
|
|
const handlePointerUp = () => {
|
|
if (swipeOut || !dismissible)
|
|
return;
|
|
pointerStart = null;
|
|
const swipeAmountX = Number(toastRef?.style
|
|
.getPropertyValue('--swipe-amount-x')
|
|
.replace('px', '') || 0);
|
|
const swipeAmountY = Number(toastRef?.style
|
|
.getPropertyValue('--swipe-amount-y')
|
|
.replace('px', '') || 0);
|
|
const timeTaken = new Date().getTime() - (dragStartTime?.getTime() ?? 0);
|
|
const swipeAmount = swipeDirection === 'x' ? swipeAmountX : swipeAmountY;
|
|
const velocity = Math.abs(swipeAmount) / timeTaken;
|
|
// remove only if threshold is met
|
|
if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) {
|
|
offsetBeforeRemove = offset;
|
|
toast.onDismiss?.(toast);
|
|
if (swipeDirection === 'x') {
|
|
swipeOutDirection = swipeAmountX > 0 ? 'right' : 'left';
|
|
}
|
|
else {
|
|
swipeOutDirection = swipeAmountY > 0 ? 'down' : 'up';
|
|
}
|
|
deleteToast();
|
|
swipeOut = true;
|
|
return;
|
|
}
|
|
else {
|
|
toastRef?.style.setProperty('--swipe-amount-x', '0px');
|
|
toastRef?.style.setProperty('--swipe-amount-y', '0px');
|
|
}
|
|
isSwiped = false;
|
|
swiping = false;
|
|
swipeDirection = null;
|
|
};
|
|
const handlePointerMove = (event) => {
|
|
if (!pointerStart || !dismissible)
|
|
return;
|
|
const isHighlighted = (window.getSelection()?.toString().length ?? -1) > 0;
|
|
if (isHighlighted)
|
|
return;
|
|
const yDelta = event.clientY - pointerStart.y;
|
|
const xDelta = event.clientX - pointerStart.x;
|
|
const swipeDirections = swipeDirectionsProp ?? getDefaultSwipeDirections(position);
|
|
// Determine swipe direction if not already locked
|
|
if (!swipeDirection && (Math.abs(xDelta) > 1 || Math.abs(yDelta) > 1)) {
|
|
swipeDirection = Math.abs(xDelta) > Math.abs(yDelta) ? 'x' : 'y';
|
|
}
|
|
let swipeAmount = { x: 0, y: 0 };
|
|
if (swipeDirection === 'y') {
|
|
// handle vertical swipes
|
|
if (swipeDirections.includes('top') ||
|
|
swipeDirections.includes('bottom')) {
|
|
if ((swipeDirections.includes('top') && yDelta < 0) ||
|
|
(swipeDirections.includes('bottom') && yDelta > 0)) {
|
|
swipeAmount.y = yDelta;
|
|
}
|
|
else {
|
|
// smoothly transition to dampened movement
|
|
const dampenedDelta = yDelta * getDampening(yDelta);
|
|
// ensure we don't jump when transition to dampened movement
|
|
swipeAmount.y =
|
|
Math.abs(dampenedDelta) < Math.abs(yDelta)
|
|
? dampenedDelta
|
|
: yDelta;
|
|
}
|
|
}
|
|
}
|
|
else if (swipeDirection === 'x') {
|
|
// handle horizontal swipes
|
|
if (swipeDirections.includes('left') ||
|
|
swipeDirections.includes('right')) {
|
|
if ((swipeDirections.includes('left') && xDelta < 0) ||
|
|
(swipeDirections.includes('right') && xDelta > 0)) {
|
|
swipeAmount.x = xDelta;
|
|
}
|
|
else {
|
|
// Smoothly transition to dampened movement
|
|
const dampenedDelta = xDelta * getDampening(xDelta);
|
|
// Ensure we don't jump when transitioning to dampened movement
|
|
swipeAmount.x =
|
|
Math.abs(dampenedDelta) < Math.abs(xDelta)
|
|
? dampenedDelta
|
|
: xDelta;
|
|
}
|
|
}
|
|
}
|
|
if (Math.abs(swipeAmount.x) > 0 || Math.abs(swipeAmount.y) > 0) {
|
|
isSwiped = true;
|
|
}
|
|
toastRef?.style.setProperty('--swipe-amount-x', `${swipeAmount.x}px`);
|
|
toastRef?.style.setProperty('--swipe-amount-y', `${swipeAmount.y}px`);
|
|
};
|
|
const handleDragEnd = () => {
|
|
swiping = false;
|
|
swipeDirection = null;
|
|
pointerStart = null;
|
|
};
|
|
const icon = $derived.by(() => {
|
|
if (toast.icon)
|
|
return toast.icon;
|
|
if (toastType === 'success')
|
|
return successIcon;
|
|
if (toastType === 'error')
|
|
return errorIcon;
|
|
if (toastType === 'warning')
|
|
return warningIcon;
|
|
if (toastType === 'info')
|
|
return infoIcon;
|
|
if (toastType === 'loading')
|
|
return loadingIcon;
|
|
return null;
|
|
});
|
|
</script>
|
|
|
|
{#snippet LoadingIcon()}
|
|
{#if loadingIcon}
|
|
<div
|
|
class={cn(classes?.loader, toast?.classes?.loader, 'sonner-loader')}
|
|
data-visible={toastType === 'loading'}
|
|
>
|
|
{@render loadingIcon()}
|
|
</div>
|
|
{:else}
|
|
<Loader
|
|
class={cn(classes?.loader, toast.classes?.loader)}
|
|
visible={toastType === 'loading'}
|
|
/>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
<li
|
|
tabindex={0}
|
|
bind:this={toastRef}
|
|
class={cn(
|
|
restProps.class,
|
|
toastClass,
|
|
classes?.toast,
|
|
toast?.classes?.toast,
|
|
classes?.[toastType],
|
|
toast?.classes?.[toastType]
|
|
)}
|
|
aria-live={toast.important ? 'assertive' : 'polite'}
|
|
aria-atomic="true"
|
|
data-sonner-toast=""
|
|
data-rich-colors={toast.richColors ?? defaultRichColors}
|
|
data-styled={!(toast.component || toast.unstyled || unstyled)}
|
|
data-mounted={mounted}
|
|
data-promise={Boolean(toast.promise)}
|
|
data-swiped={isSwiped}
|
|
data-removed={removed}
|
|
data-visible={isVisible}
|
|
data-y-position={coords[0]}
|
|
data-x-position={coords[1]}
|
|
data-index={index}
|
|
data-front={isFront}
|
|
data-swiping={swiping}
|
|
data-dismissible={dismissible}
|
|
data-type={toastType}
|
|
data-invert={invert}
|
|
data-swipe-out={swipeOut}
|
|
data-swipe-direction={swipeOutDirection}
|
|
data-expanded={Boolean(expanded || (expandByDefault && mounted))}
|
|
style:--index={index}
|
|
style:--toasts-before={index}
|
|
style:--z-index={toastState.toasts.length - index}
|
|
style:--offset={`${removed ? offsetBeforeRemove : offset}px`}
|
|
style:--initial-height={expandByDefault ? 'auto' : `${initialHeight}px`}
|
|
style={`${restProps.style} ${toast.style}`}
|
|
onpointermove={handlePointerMove}
|
|
onpointerup={handlePointerUp}
|
|
onpointerdown={handlePointerDown}
|
|
ondragend={handleDragEnd}
|
|
>
|
|
{#if closeButton && !toast.component && toastType !== 'loading' && closeIcon !== null}
|
|
<button
|
|
aria-label={closeButtonAriaLabel}
|
|
data-disabled={disabled}
|
|
data-close-button
|
|
onclick={() => {
|
|
if (disabled || !dismissible) return;
|
|
deleteToast();
|
|
toast.onDismiss?.(toast);
|
|
}}
|
|
class={cn(classes?.closeButton, toast?.classes?.closeButton)}
|
|
>
|
|
{@render closeIcon?.()}
|
|
</button>
|
|
{/if}
|
|
|
|
{#if toast.component}
|
|
{@const Component = toast.component}
|
|
<Component {...toast.componentProps} closeToast={deleteToast} />
|
|
{:else}
|
|
{#if (toastType || toast.icon || toast.promise) && toast.icon !== null && (icon !== null || toast.icon)}
|
|
<div data-icon="" class={cn(classes?.icon, toast?.classes?.icon)}>
|
|
{#if toast.promise || toastType === 'loading'}
|
|
{#if toast.icon}
|
|
<toast.icon />
|
|
{:else}
|
|
{@render LoadingIcon()}
|
|
{/if}
|
|
{/if}
|
|
{#if toast.type !== 'loading'}
|
|
{#if toast.icon}
|
|
<toast.icon />
|
|
{:else if toastType === 'success'}
|
|
{@render successIcon?.()}
|
|
{:else if toastType === 'error'}
|
|
{@render errorIcon?.()}
|
|
{:else if toastType === 'warning'}
|
|
{@render warningIcon?.()}
|
|
{:else if toastType === 'info'}
|
|
{@render infoIcon?.()}
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
<div data-content="" class={cn(classes?.content, toast?.classes?.content)}>
|
|
<div
|
|
data-title=""
|
|
class={cn(classes?.title, toast?.classes?.title)}
|
|
>
|
|
{#if toast.title}
|
|
{#if typeof toast.title !== 'string'}
|
|
{@const Title = toast.title}
|
|
<Title {...toast.componentProps} />
|
|
{:else}
|
|
{toast.title}
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{#if toast.description}
|
|
<div
|
|
data-description=""
|
|
class={cn(
|
|
descriptionClass,
|
|
toastDescriptionClass,
|
|
classes?.description,
|
|
toast.classes?.description
|
|
)}
|
|
>
|
|
{#if typeof toast.description !== 'string'}
|
|
{@const Description = toast.description}
|
|
<Description {...toast.componentProps} />
|
|
{:else}
|
|
{toast.description}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{#if toast.cancel}
|
|
{#if typeof toast.cancel === 'function'}
|
|
<toast.cancel />
|
|
{:else if isAction(toast.cancel)}
|
|
<button
|
|
data-button
|
|
data-cancel
|
|
style={toast.cancelButtonStyle ?? cancelButtonStyle}
|
|
class={cn(
|
|
classes?.cancelButton,
|
|
toast?.classes?.cancelButton
|
|
)}
|
|
onclick={(event) => {
|
|
if (!isAction(toast.cancel)) return;
|
|
if (!dismissible) return;
|
|
toast.cancel?.onClick?.(event);
|
|
deleteToast();
|
|
}}
|
|
>
|
|
{toast.cancel.label}
|
|
</button>
|
|
{/if}
|
|
{/if}
|
|
{#if toast.action}
|
|
{#if typeof toast.action === 'function'}
|
|
<toast.action />
|
|
{:else if isAction(toast.action)}
|
|
<button
|
|
data-button=""
|
|
style={toast.actionButtonStyle ?? actionButtonStyle}
|
|
class={cn(
|
|
classes?.actionButton,
|
|
toast?.classes?.actionButton
|
|
)}
|
|
onclick={(event) => {
|
|
if (!isAction(toast.action)) return;
|
|
toast.action?.onClick(event);
|
|
if (event.defaultPrevented) return;
|
|
deleteToast();
|
|
}}
|
|
>
|
|
{toast.action.label}
|
|
</button>
|
|
{/if}
|
|
{/if}
|
|
{/if}
|
|
</li>
|