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/desktop.ts b/apps/client/src/desktop.ts index 6462c1338..e77ba845b 100644 --- a/apps/client/src/desktop.ts +++ b/apps/client/src/desktop.ts @@ -22,6 +22,7 @@ bundleService.getWidgetBundlesByParent().then(async (widgetBundles) => { appContext.setLayout(new DesktopLayout(widgetBundles)); appContext.start().catch((e) => { toastService.showPersistent({ + id: "critical-error", title: t("toast.critical-error.title"), icon: "alert", message: t("toast.critical-error.message", { message: e.message }) 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/branches.ts b/apps/client/src/services/branches.ts index 0613ea4ea..e5a5158e1 100644 --- a/apps/client/src/services/branches.ts +++ b/apps/client/src/services/branches.ts @@ -1,6 +1,6 @@ import utils from "./utils.js"; import server from "./server.js"; -import toastService, { type ToastOptions } from "./toast.js"; +import toastService, { type ToastOptionsWithRequiredId } from "./toast.js"; import froca from "./froca.js"; import hoistedNoteService from "./hoisted_note.js"; import ws from "./ws.js"; @@ -195,11 +195,11 @@ function filterRootNote(branchIds: string[]) { }); } -function makeToast(id: string, message: string): ToastOptions { +function makeToast(id: string, message: string): ToastOptionsWithRequiredId { return { - id: id, + id, title: t("branches.delete-status"), - message: message, + message, icon: "trash" }; } @@ -216,7 +216,7 @@ ws.subscribeToMessages(async (message) => { toastService.showPersistent(makeToast(message.taskId, t("branches.delete-notes-in-progress", { count: message.progressCount }))); } else if (message.type === "taskSucceeded") { const toast = makeToast(message.taskId, t("branches.delete-finished-successfully")); - toast.closeAfter = 5000; + toast.timeout = 5000; toastService.showPersistent(toast); } @@ -234,7 +234,7 @@ ws.subscribeToMessages(async (message) => { toastService.showPersistent(makeToast(message.taskId, t("branches.undeleting-notes-in-progress", { count: message.progressCount }))); } else if (message.type === "taskSucceeded") { const toast = makeToast(message.taskId, t("branches.undeleting-notes-finished-successfully")); - toast.closeAfter = 5000; + toast.timeout = 5000; toastService.showPersistent(toast); } @@ -242,7 +242,7 @@ ws.subscribeToMessages(async (message) => { async function cloneNoteToBranch(childNoteId: string, parentBranchId: string, prefix?: string) { const resp = await server.put(`notes/${childNoteId}/clone-to-branch/${parentBranchId}`, { - prefix: prefix + prefix }); if (!resp.success) { @@ -252,7 +252,7 @@ async function cloneNoteToBranch(childNoteId: string, parentBranchId: string, pr async function cloneNoteToParentNote(childNoteId: string, parentNoteId: string, prefix?: string) { const resp = await server.put(`notes/${childNoteId}/clone-to-note/${parentNoteId}`, { - prefix: prefix + prefix }); if (!resp.success) { diff --git a/apps/client/src/services/bundle.ts b/apps/client/src/services/bundle.ts index c346fce8d..e925253ce 100644 --- a/apps/client/src/services/bundle.ts +++ b/apps/client/src/services/bundle.ts @@ -37,6 +37,7 @@ export async function executeBundle(bundle: Bundle, originEntity?: Entity | null } catch (e: any) { const note = await froca.getNote(bundle.noteId); toastService.showPersistent({ + id: `custom-script-failure-${note?.noteId}`, title: t("toast.bundle-error.title"), icon: "bx bx-error-circle", message: t("toast.bundle-error.message", { @@ -108,6 +109,7 @@ async function getWidgetBundlesByParent() { const noteId = bundle.noteId; const note = await froca.getNote(noteId); toastService.showPersistent({ + id: `custom-script-failure-${noteId}`, title: t("toast.bundle-error.title"), icon: "bx bx-error-circle", message: t("toast.bundle-error.message", { diff --git a/apps/client/src/services/import.ts b/apps/client/src/services/import.ts index 2300ca101..80f057282 100644 --- a/apps/client/src/services/import.ts +++ b/apps/client/src/services/import.ts @@ -1,4 +1,4 @@ -import toastService, { type ToastOptions } from "./toast.js"; +import toastService, { type ToastOptionsWithRequiredId } from "./toast.js"; import server from "./server.js"; import ws from "./ws.js"; import utils from "./utils.js"; @@ -57,11 +57,11 @@ export async function uploadFiles(entityType: string, parentNoteId: string, file } } -function makeToast(id: string, message: string): ToastOptions { +function makeToast(id: string, message: string): ToastOptionsWithRequiredId { return { - id: id, + id, title: t("import.import-status"), - message: message, + message, icon: "plus" }; } @@ -78,7 +78,7 @@ ws.subscribeToMessages(async (message) => { toastService.showPersistent(makeToast(message.taskId, t("import.in-progress", { progress: message.progressCount }))); } else if (message.type === "taskSucceeded") { const toast = makeToast(message.taskId, t("import.successful")); - toast.closeAfter = 5000; + toast.timeout = 5000; toastService.showPersistent(toast); @@ -100,7 +100,7 @@ ws.subscribeToMessages(async (message: WebSocketMessage) => { toastService.showPersistent(makeToast(message.taskId, t("import.in-progress", { progress: message.progressCount }))); } else if (message.type === "taskSucceeded") { const toast = makeToast(message.taskId, t("import.successful")); - toast.closeAfter = 5000; + toast.timeout = 5000; toastService.showPersistent(toast); diff --git a/apps/client/src/services/protected_session.ts b/apps/client/src/services/protected_session.ts index 1e1984ae5..395a844bb 100644 --- a/apps/client/src/services/protected_session.ts +++ b/apps/client/src/services/protected_session.ts @@ -1,7 +1,7 @@ import server from "./server.js"; import protectedSessionHolder from "./protected_session_holder.js"; import toastService from "./toast.js"; -import type { ToastOptions } from "./toast.js"; +import type { ToastOptionsWithRequiredId } from "./toast.js"; import ws from "./ws.js"; import appContext from "../components/app_context.js"; import froca from "./froca.js"; @@ -97,7 +97,7 @@ async function protectNote(noteId: string, protect: boolean, includingSubtree: b await server.put(`notes/${noteId}/protect/${protect ? 1 : 0}?subtree=${includingSubtree ? 1 : 0}`); } -function makeToast(message: Message, title: string, text: string): ToastOptions { +function makeToast(message: Message, title: string, text: string): ToastOptionsWithRequiredId { return { id: message.taskId, title, @@ -124,7 +124,7 @@ ws.subscribeToMessages(async (message) => { } else if (message.type === "taskSucceeded") { const text = isProtecting ? t("protected_session.protecting-finished-successfully") : t("protected_session.unprotecting-finished-successfully"); const toast = makeToast(message, title, text); - toast.closeAfter = 3000; + toast.timeout = 3000; toastService.showPersistent(toast); } diff --git a/apps/client/src/services/toast.ts b/apps/client/src/services/toast.ts index 8c55efeee..abbbfff42 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 { @@ -5,117 +7,80 @@ export interface ToastOptions { icon: string; title?: string; message: string; - delay?: number; - autohide?: boolean; - closeAfter?: number; + timeout?: number; 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") { +function showMessage(message: string, timeout = 2000, icon = "bx bx-check") { console.debug(utils.now(), "message:", message); - toast({ + addToast({ icon, - message: message, - autohide: true, - delay + message, + timeout }); } -export function showError(message: string, delay = 10000) { +export function showError(message: string, timeout = 10000) { console.log(utils.now(), "error: ", message); - toast({ + addToast({ icon: "bx bx-error-circle", - message: message, - autohide: true, - delay + message, + timeout }); } -function showErrorTitleAndMessage(title: string, message: string, delay = 10000) { +function showErrorTitleAndMessage(title: string, message: string, timeout = 10000) { console.log(utils.now(), "error: ", message); - toast({ - title: title, + addToast({ + title, icon: "bx bx-error-circle", - message: message, - autohide: true, - delay + message, + timeout }); } +//#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..f2f554629 100644 --- a/apps/client/src/stylesheets/style.css +++ b/apps/client/src/stylesheets/style.css @@ -1135,61 +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); - z-index: 9999999999 !important; - pointer-events: all; - overflow: hidden; -} - -.toast-header { - background-color: var(--more-accented-background-color) !important; - color: var(--main-text-color) !important; -} - -.toast-body { - white-space: preserve-breaks; - overflow: hidden; -} - -.toast.no-title { - display: flex; - flex-direction: row; -} - -.toast.no-title .toast-icon { - display: flex; - align-items: center; - padding: var(--bs-toast-padding-y) var(--bs-toast-padding-x); -} - -.toast.no-title .toast-body { - padding-inline-start: 0; - padding-inline-end: 0; -} - -.toast.no-title .toast-header { - background-color: unset !important; -} - -.toast .toast-progress { - position: absolute; - bottom: 0; - inset-inline-start: 0; - inset-inline-end: 0; - background-color: var(--toast-text-color) !important; - height: 4px; - transition: width 0.1s linear; -} - .ck-mentions .ck-button { font-size: var(--detail-font-size) !important; padding: 5px; diff --git a/apps/client/src/widgets/Toast.css b/apps/client/src/widgets/Toast.css new file mode 100644 index 000000000..457aadce2 --- /dev/null +++ b/apps/client/src/widgets/Toast.css @@ -0,0 +1,59 @@ +#toast-container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + position: absolute; + width: 100%; + top: 20px; + pointer-events: none; + contain: none; +} + +.toast { + --bs-toast-bg: var(--accented-background-color); + --bs-toast-color: var(--main-text-color); + z-index: 9999999999 !important; + pointer-events: all; + overflow: hidden; +} + +.toast-header { + background-color: var(--more-accented-background-color) !important; + color: var(--main-text-color) !important; +} + +.toast-body { + white-space: preserve-breaks; + overflow: hidden; +} + +.toast.no-title { + display: flex; + flex-direction: row; +} + +.toast.no-title .toast-icon { + display: flex; + align-items: center; + padding: var(--bs-toast-padding-y) var(--bs-toast-padding-x); +} + +.toast.no-title .toast-body { + padding-inline-start: 0; + padding-inline-end: 0; +} + +.toast.no-title .toast-header { + background-color: unset !important; +} + +.toast .toast-progress { + position: absolute; + bottom: 0; + inset-inline-start: 0; + inset-inline-end: 0; + background-color: var(--toast-text-color) !important; + height: 4px; + transition: width 0.1s linear; +} diff --git a/apps/client/src/widgets/Toast.tsx b/apps/client/src/widgets/Toast.tsx new file mode 100644 index 000000000..62bfe78fd --- /dev/null +++ b/apps/client/src/widgets/Toast.tsx @@ -0,0 +1,61 @@ +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"; + +export default function ToastContainer() { + return ( +
+ {toasts.value.map(toast => )} +
+ ) +} + +function Toast({ id, title, timeout, progress, message, icon }: ToastOptionsWithRequiredId) { + // Autohide. + useEffect(() => { + if (!timeout || timeout <= 0) return; + const timerId = setTimeout(() => removeToastFromStore(id), timeout); + return () => clearTimeout(timerId); + }, [ id, timeout ]); + + const closeButton = ( +