view libervia/web/pages/_browser/dialog.py @ 1635:332822ceae85

browser (chat): Add rich editor, forward and extra recipients: A new "extra" menu is now available next to input field, allowing to toggle to rich editor. Rich editors allows message styling using bold, italic, underline, (un)numbered list and link. Other features will probably follow with time. An extra menu item allows to add recipients, with `to`, `cc` or `bcc` flag like for emails. Messages can now be forwarded to any entity with a new item in the 3 dots menu. rel 461
author Goffi <goffi@goffi.org>
date Fri, 04 Jul 2025 17:47:37 +0200
parents 698eaabfca0e
children
line wrap: on
line source

"""manage common dialogs"""

from typing import Callable, Literal
from browser import document, window, timer, console as log
from template import Template

log.warning = log.warn


class CancelError(Exception):
    """Dialog is cancelled"""

    def __init__(self, reason: str = "", text: str = "") -> None:
        self.reason = reason
        self.text = text
        super().__init__(text)


class Confirm:

    def __init__(self, message, ok_label="", cancel_label="", ok_color="success"):
        self._tpl = Template("dialogs/confirm.html")
        self.message = message
        self.ok_label = ok_label
        assert ok_color in ("success", "danger")
        self.ok_color = ok_color
        self.cancel_label = cancel_label
        self._notif_elt = None
        self.reset()

    def reset(self):
        """Reset values of callbacks and notif element"""
        self._ok_cb = None
        self._cancel_cb = None
        self._reject_cb = None
        if self._notif_elt is not None:
            self._notif_elt.remove()
            self._notif_elt = None

    def _default_cancel_cb(self, evt, notif_elt):
        notif_elt.remove()

    def cancel(self, reason: str = "", text: str = ""):
        """Cancel the dialog, without calling any callback

        will raise a CancelError
        """
        if self._notif_elt is None:
            log.warning("calling cancel on an unshown dialog")
        else:
            self._notif_elt.remove()
            self._notif_elt = None
            if self._reject_cb is not None:
                self._reject_cb(CancelError(reason, text))
            else:
                log.warning("no reject callback set")
        self.reset()

    def on_ok_click(self, evt):
        evt.preventDefault()
        evt.stopPropagation()
        assert self._ok_cb is not None
        self._ok_cb(evt, self._notif_elt)
        self.reset()

    def on_cancel_click(self, evt) -> None:
        evt.preventDefault()
        evt.stopPropagation()
        assert self._cancel_cb is not None
        self._cancel_cb(evt, self._notif_elt)
        self.reset()

    def show(self, ok_cb, cancel_cb=None, reject_cb=None):
        if cancel_cb is None:
            cancel_cb = self._default_cancel_cb
        self._ok_cb = ok_cb
        self._cancel_cb = cancel_cb
        self._reject_cb = reject_cb
        notif_elt = self._tpl.get_elt({
            "message": self.message,
            "ok_label": self.ok_label,
            "ok_color": self.ok_color,
            "cancel_label": self.cancel_label,
        })
        self._notif_elt = notif_elt

        document['notifs_area'] <= notif_elt
        timer.set_timeout(lambda: notif_elt.classList.add('state_appended'), 0)
        for ok_elt in notif_elt.select(".click_to_ok"):
            ok_elt.bind("click", self.on_ok_click)
        for ok_elt in notif_elt.select(".click_to_cancel"):
            ok_elt.bind("click", self.on_cancel_click)

    def _ashow_cb(self, evt, notif_elt, resolve_cb, confirmed):
        evt.stopPropagation()
        notif_elt.remove()
        resolve_cb(confirmed)

    async def ashow(self) -> bool:
        """Return True if the dialog is confirmed."""
        return window.Promise.new(
            lambda resolve_cb, reject_cb:
            self.show(
                lambda evt, notif_elt: self._ashow_cb(evt, notif_elt, resolve_cb, True),
                lambda evt, notif_elt: self._ashow_cb(evt, notif_elt, resolve_cb, False),
                reject_cb
            )
        )


class Notification:

    def __init__(self):
        self._tpl = Template("dialogs/notification.html")

    def close(self, notif_elt):
        notif_elt.classList.remove('state_appended')
        notif_elt.bind("transitionend", lambda __: notif_elt.remove())

    def show(
        self,
        message: str,
        level: str = "info",
        delay: int = 5
    ) -> None:
        # we log in console error messages, may be useful
        if level in ("warning", "error"):
            print(f"[{level}] {message}")
        notif_elt = self._tpl.get_elt({
            "message": message,
            "level": level,
        })
        document["notifs_area"] <= notif_elt
        timer.set_timeout(lambda: notif_elt.classList.add('state_appended'), 0)
        timer.set_timeout(lambda: self.close(notif_elt), delay * 1000)
        for elt in notif_elt.select('.click_to_close'):
            elt.bind('click', lambda __: self.close(notif_elt))


class RetryNotification:
    def __init__(self, retry_cb):
        self._tpl = Template("dialogs/retry-notification.html")
        self.retry_cb = retry_cb
        self.counter = 0
        self.timer = None

    def retry(self, notif_elt):
        if self.timer is not None:
            timer.clear_interval(self.timer)
            self.timer = None
        notif_elt.classList.remove('state_appended')
        notif_elt.bind("transitionend", lambda __: notif_elt.remove())
        self.retry_cb()

    def update_counter(self, notif_elt):
        counter = notif_elt.select_one(".retry_counter")
        counter.text = str(self.counter)
        self.counter -= 1
        if self.counter < 0:
            self.retry(notif_elt)

    def show(
        self,
        message: str,
        level: str = "warning",
        delay: int = 5
    ) -> None:
        # we log in console error messages, may be useful
        if level == "error":
            log.error(message)
        elif level == "warning":
            log.warning(message)
        self.counter = delay
        notif_elt = self._tpl.get_elt({
            "message": message,
            "level": level,
        })
        self.update_counter(notif_elt)
        document["notifs_area"] <= notif_elt
        timer.set_timeout(lambda: notif_elt.classList.add('state_appended'), 0)
        self.timer = timer.set_interval(self.update_counter, 1000, notif_elt)
        for elt in notif_elt.select('.click_to_retry'):
            elt.bind('click', lambda __: self.retry(notif_elt))


class Modal:

    def __init__(
        self,
        content_elt,
        is_card: bool = False,
        closable: bool = False,
        close_cb: Callable|None = None,
        position: Literal["center", "left", "right", "top", "bottom"] = "center"
    ) -> None:
        self.position = position
        self.is_card = is_card
        if is_card and position == 'center':
            if not content_elt.classList.contains("modal-card"):
                raise ValueError(
                    'Element must have a "modal-card" class when `is_card` is used for center modal'
                )
        self.closable = closable
        self._close_cb = close_cb
        self._tpl = Template("dialogs/modal.html")
        self.content_elt = content_elt
        self._modal_elt = None
        self._closing = False
        self._is_panel = position != 'center'

    def _cleanup(self):
        if self._modal_elt is not None:
            self._modal_elt.remove()
            self._modal_elt = None
        self._closing = False

    def close(self):
        if self._modal_elt is None or self._closing:
            return

        self._closing = True

        self._modal_elt.classList.remove('is-active')

        if self._is_panel:
            self._modal_elt.classList.add('closing')

        def on_close_finished():
            if self._close_cb:
                self._close_cb()
            self._cleanup()

        timer.set_timeout(on_close_finished, 300)

    def on_background_click(self, evt):
        if self.closable and not self._closing:
            evt.preventDefault()
            evt.stopPropagation()
            self.close()

    def show(self) -> None:
        if self._modal_elt:
            self._cleanup()

        self._modal_elt = self._tpl.get_elt({
            "closable": self.closable,
            "position": self.position,
            "is_panel": self._is_panel
        })

        if self.position == 'center':
            container_elt = self._modal_elt.select_one(".modal-content")
            container_elt <= self.content_elt
        else:
            container_elt = self._modal_elt.select_one(".modal-panel-container")
            container_elt <= self.content_elt
            container_elt.classList.add(f"is-{self.position}")

        document['notifs_area'] <= self._modal_elt

        if self.closable:
            bg = self._modal_elt.select_one(".modal-background, .modal-panel-background")
            if bg:
                bg.bind("click", self.on_background_click)

        # Add active class after a small delay to trigger animation
        timer.set_timeout(lambda: self._modal_elt.classList.add('is-active'), 10)


notification = Notification()