diff --git a/apps/client/package.json b/apps/client/package.json index 91eaad1bc..b2d084008 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -27,6 +27,7 @@ "@mermaid-js/layout-elk": "0.2.0", "@mind-elixir/node-menu": "5.0.1", "@popperjs/core": "2.11.8", + "@preact/signals": "2.5.1", "@triliumnext/ckeditor5": "workspace:*", "@triliumnext/codemirror": "workspace:*", "@triliumnext/commons": "workspace:*", diff --git a/apps/client/src/layouts/layout_commons.tsx b/apps/client/src/layouts/layout_commons.tsx index 62f810430..3620d495d 100644 --- a/apps/client/src/layouts/layout_commons.tsx +++ b/apps/client/src/layouts/layout_commons.tsx @@ -24,6 +24,7 @@ import InfoDialog from "../widgets/dialogs/info.js"; import IncorrectCpuArchDialog from "../widgets/dialogs/incorrect_cpu_arch.js"; import CallToActionDialog from "../widgets/dialogs/call_to_action.jsx"; import PopupEditorDialog from "../widgets/dialogs/PopupEditor.jsx"; +import ToastContainer from "../widgets/Toast.jsx"; export function applyModals(rootContainer: RootContainer) { rootContainer @@ -50,5 +51,6 @@ export function applyModals(rootContainer: RootContainer) { .child() .child() .child() - .child(); + .child() + .child() } diff --git a/apps/client/src/services/toast.ts b/apps/client/src/services/toast.ts index 8c55efeee..c99353821 100644 --- a/apps/client/src/services/toast.ts +++ b/apps/client/src/services/toast.ts @@ -1,3 +1,5 @@ +import { signal } from "@preact/signals"; + import utils from "./utils.js"; export interface ToastOptions { @@ -11,83 +13,28 @@ export interface ToastOptions { progress?: number; } -function toast({ title, icon, message, id, delay, autohide, progress }: ToastOptions) { - const $toast = $(title - ? `\ - ` - : ` - ` - ); +export type ToastOptionsWithRequiredId = Omit & Required>; - $toast.toggleClass("no-title", !title); - $toast.find(".toast-title").text(title ?? ""); - $toast.find(".toast-body").html(message); - $toast.find(".toast-progress").css("width", `${(progress ?? 0) * 100}%`); - - if (id) { - $toast.attr("id", `toast-${id}`); - } - - $("#toast-container").append($toast); - - $toast.toast({ - delay: delay || 3000, - autohide: !!autohide - }); - - $toast.on("hidden.bs.toast", (e) => e.target.remove()); - - $toast.toast("show"); - - return $toast; -} - -function showPersistent(options: ToastOptions) { - let $toast = $(`#toast-${options.id}`); - - if ($toast.length > 0) { - $toast.find(".toast-body").html(options.message); - $toast.find(".toast-progress").css("width", `${(options.progress ?? 0) * 100}%`); +function showPersistent(options: ToastOptionsWithRequiredId) { + const existingToast = toasts.value.find(toast => toast.id === options.id); + if (existingToast) { + updateToast(options.id, options); } else { options.autohide = false; - - $toast = toast(options); - } - - if (options.closeAfter) { - setTimeout(() => $toast.remove(), options.closeAfter); + addToast(options); } } function closePersistent(id: string) { - $(`#toast-${id}`).remove(); + removeToastFromStore(id); } function showMessage(message: string, delay = 2000, icon = "check") { console.debug(utils.now(), "message:", message); - toast({ + addToast({ icon, - message: message, + message, autohide: true, delay }); @@ -96,26 +43,50 @@ function showMessage(message: string, delay = 2000, icon = "check") { export function showError(message: string, delay = 10000) { console.log(utils.now(), "error: ", message); - toast({ + addToast({ icon: "bx bx-error-circle", - message: message, + message, autohide: true, delay - }); + }) } function showErrorTitleAndMessage(title: string, message: string, delay = 10000) { console.log(utils.now(), "error: ", message); - toast({ - title: title, + addToast({ + title, icon: "bx bx-error-circle", - message: message, + message, autohide: true, delay }); } +//#region Toast store +export const toasts = signal([]); + +function addToast(opts: ToastOptions) { + const id = opts.id ?? crypto.randomUUID(); + const toast = { ...opts, id }; + toasts.value = [ ...toasts.value, toast ]; + return id; +} + +function updateToast(id: string, partial: Partial) { + toasts.value = toasts.value.map(toast => { + if (toast.id === id) { + return { ...toast, ...partial } + } + return toast; + }) +} + +export function removeToastFromStore(id: string) { + toasts.value = toasts.value.filter(toast => toast.id !== id); +} +//#endregion + export default { showMessage, showError, diff --git a/apps/client/src/stylesheets/style.css b/apps/client/src/stylesheets/style.css index e395dbbb3..ade2687fb 100644 --- a/apps/client/src/stylesheets/style.css +++ b/apps/client/src/stylesheets/style.css @@ -1135,13 +1135,6 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href margin: 0 12px; } -#toast-container { - position: absolute; - width: 100%; - top: 20px; - pointer-events: none; -} - .toast { --bs-toast-bg: var(--accented-background-color); --bs-toast-color: var(--main-text-color); diff --git a/apps/client/src/widgets/Toast.css b/apps/client/src/widgets/Toast.css new file mode 100644 index 000000000..720a5c0d4 --- /dev/null +++ b/apps/client/src/widgets/Toast.css @@ -0,0 +1,11 @@ +#toast-container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + position: absolute; + width: 100%; + top: 20px; + pointer-events: none; + contain: none; +} diff --git a/apps/client/src/widgets/Toast.tsx b/apps/client/src/widgets/Toast.tsx new file mode 100644 index 000000000..643cad551 --- /dev/null +++ b/apps/client/src/widgets/Toast.tsx @@ -0,0 +1,58 @@ +import "./Toast.css"; + +import clsx from "clsx"; +import { useEffect } from "preact/hooks"; + +import { removeToastFromStore, ToastOptionsWithRequiredId, toasts } from "../services/toast"; +import Icon from "./react/Icon"; +import { RawHtmlBlock } from "./react/RawHtml"; + +const DEFAULT_DELAY = 3_000; + +export default function ToastContainer() { + return ( +
+ {toasts.value.map(toast => )} +
+ ) +} + +function Toast({ id, title, autohide, delay, progress, message, icon }: ToastOptionsWithRequiredId) { + // Autohide. + useEffect(() => { + if (!autohide || !id) return; + const timeout = setTimeout(() => removeToastFromStore(id), delay || DEFAULT_DELAY); + return () => clearTimeout(timeout); + }, [ autohide, id, delay ]); + + const closeButton =