banner.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
  1. """Banner components."""
  2. from __future__ import annotations
  3. from reflex import constants
  4. from reflex.components.base.fragment import Fragment
  5. from reflex.components.component import Component
  6. from reflex.components.core.cond import cond
  7. from reflex.components.el.elements.typography import Div
  8. from reflex.components.lucide.icon import Icon
  9. from reflex.components.radix.themes.components.dialog import (
  10. DialogContent,
  11. DialogRoot,
  12. DialogTitle,
  13. )
  14. from reflex.components.radix.themes.layout.flex import Flex
  15. from reflex.components.radix.themes.typography.text import Text
  16. from reflex.components.sonner.toast import ToastProps, toast_ref
  17. from reflex.config import environment
  18. from reflex.constants import Dirs, Hooks, Imports
  19. from reflex.constants.compiler import CompileVars
  20. from reflex.utils.imports import ImportVar
  21. from reflex.vars import VarData
  22. from reflex.vars.base import LiteralVar, Var
  23. from reflex.vars.function import FunctionStringVar
  24. from reflex.vars.number import BooleanVar
  25. from reflex.vars.sequence import LiteralArrayVar
  26. connect_error_var_data: VarData = VarData(
  27. imports=Imports.EVENTS,
  28. hooks={Hooks.EVENTS: None},
  29. )
  30. connect_errors = Var(
  31. _js_expr=CompileVars.CONNECT_ERROR, _var_data=connect_error_var_data
  32. )
  33. connection_error = Var(
  34. _js_expr="((connectErrors.length > 0) ? connectErrors[connectErrors.length - 1].message : '')",
  35. _var_data=connect_error_var_data,
  36. )
  37. connection_errors_count = Var(
  38. _js_expr="connectErrors.length", _var_data=connect_error_var_data
  39. )
  40. has_connection_errors = Var(
  41. _js_expr="(connectErrors.length > 0)", _var_data=connect_error_var_data
  42. ).to(BooleanVar)
  43. has_too_many_connection_errors = Var(
  44. _js_expr="(connectErrors.length >= 2)", _var_data=connect_error_var_data
  45. ).to(BooleanVar)
  46. class WebsocketTargetURL(Var):
  47. """A component that renders the websocket target URL."""
  48. @classmethod
  49. def create(cls) -> Var:
  50. """Create a websocket target URL component.
  51. Returns:
  52. The websocket target URL component.
  53. """
  54. return Var(
  55. _js_expr="getBackendURL(env.EVENT).href",
  56. _var_data=VarData(
  57. imports={
  58. "$/env.json": [ImportVar(tag="env", is_default=True)],
  59. f"$/{Dirs.STATE_PATH}": [ImportVar(tag="getBackendURL")],
  60. },
  61. ),
  62. _var_type=WebsocketTargetURL,
  63. )
  64. def default_connection_error() -> list[str | Var | Component]:
  65. """Get the default connection error message.
  66. Returns:
  67. The default connection error message.
  68. """
  69. return [
  70. "Cannot connect to server: ",
  71. connection_error,
  72. ". Check if server is reachable at ",
  73. WebsocketTargetURL.create(),
  74. ]
  75. class ConnectionToaster(Fragment):
  76. """A connection toaster component."""
  77. def add_hooks(self) -> list[str | Var]:
  78. """Add the hooks for the connection toaster.
  79. Returns:
  80. The hooks for the connection toaster.
  81. """
  82. toast_id = "websocket-error"
  83. target_url = WebsocketTargetURL.create()
  84. props = ToastProps(
  85. description=LiteralVar.create(
  86. f"Check if server is reachable at {target_url}",
  87. ),
  88. close_button=True,
  89. duration=120000,
  90. id=toast_id,
  91. ) # pyright: ignore [reportCallIssue]
  92. if environment.REFLEX_DOES_BACKEND_COLD_START.get():
  93. loading_message = Var.create("Backend is starting.")
  94. backend_is_loading_toast_var = Var(
  95. f"toast?.loading({loading_message!s}, {{...toast_props, description: '', closeButton: false, onDismiss: () => setUserDismissed(true)}},)"
  96. )
  97. backend_is_not_responding = Var.create("Backend is not responding.")
  98. backend_is_down_toast_var = Var(
  99. f"toast?.error({backend_is_not_responding!s}, {{...toast_props, description: '', onDismiss: () => setUserDismissed(true)}},)"
  100. )
  101. toast_var = Var(
  102. f"""
  103. if (waitedForBackend) {{
  104. {backend_is_down_toast_var!s}
  105. }} else {{
  106. {backend_is_loading_toast_var!s};
  107. }}
  108. setTimeout(() => {{
  109. if ({has_too_many_connection_errors!s}) {{
  110. setWaitedForBackend(true);
  111. }}
  112. }}, {environment.REFLEX_BACKEND_COLD_START_TIMEOUT.get() * 1000});
  113. """
  114. )
  115. else:
  116. loading_message = Var.create(
  117. f"Cannot connect to server: {connection_error}."
  118. )
  119. toast_var = Var(
  120. f"toast?.error({loading_message!s}, {{...toast_props, onDismiss: () => setUserDismissed(true)}},)"
  121. )
  122. individual_hooks = [
  123. Var(f"const toast = {toast_ref};"),
  124. f"const toast_props = {LiteralVar.create(props)!s};",
  125. "const [userDismissed, setUserDismissed] = useState(false);",
  126. "const [waitedForBackend, setWaitedForBackend] = useState(false);",
  127. FunctionStringVar(
  128. "useEffect",
  129. _var_data=VarData(
  130. imports={
  131. "react": ("useEffect", "useState"),
  132. **(
  133. dict(var_data.imports)
  134. if (var_data := target_url._get_all_var_data()) is not None
  135. else {}
  136. ),
  137. }
  138. ),
  139. ).call(
  140. # TODO: This breaks the assumption that Vars are JS expressions
  141. Var(
  142. _js_expr=f"""
  143. () => {{
  144. if ({has_too_many_connection_errors!s}) {{
  145. if (!userDismissed) {{
  146. {toast_var!s}
  147. }}
  148. }} else {{
  149. toast?.dismiss("{toast_id}");
  150. setUserDismissed(false); // after reconnection reset dismissed state
  151. }}
  152. }}
  153. """
  154. ),
  155. LiteralArrayVar.create([connect_errors, Var("waitedForBackend")]),
  156. ),
  157. ]
  158. return [
  159. Hooks.EVENTS,
  160. *individual_hooks,
  161. ]
  162. @classmethod
  163. def create(cls, *children, **props) -> Component:
  164. """Create a connection toaster component.
  165. Args:
  166. *children: The children of the component.
  167. **props: The properties of the component.
  168. Returns:
  169. The connection toaster component.
  170. """
  171. return super().create(*children, **props)
  172. class ConnectionBanner(Component):
  173. """A connection banner component."""
  174. @classmethod
  175. def create(cls, comp: Component | None = None) -> Component:
  176. """Create a connection banner component.
  177. Args:
  178. comp: The component to render when there's a server connection error.
  179. Returns:
  180. The connection banner component.
  181. """
  182. if not comp:
  183. comp = Flex.create(
  184. Text.create(
  185. *default_connection_error(),
  186. color="black",
  187. size="4",
  188. ),
  189. justify="center",
  190. background_color="crimson",
  191. width="100vw",
  192. padding="5px",
  193. position="fixed",
  194. )
  195. return cond(has_connection_errors, comp)
  196. class ConnectionModal(Component):
  197. """A connection status modal window."""
  198. @classmethod
  199. def create(cls, comp: Component | None = None) -> Component:
  200. """Create a connection banner component.
  201. Args:
  202. comp: The component to render when there's a server connection error.
  203. Returns:
  204. The connection banner component.
  205. """
  206. if not comp:
  207. comp = Text.create(*default_connection_error())
  208. return cond(
  209. has_too_many_connection_errors,
  210. DialogRoot.create(
  211. DialogContent.create(
  212. DialogTitle.create("Connection Error"),
  213. comp,
  214. ),
  215. open=has_too_many_connection_errors,
  216. z_index=9999,
  217. ),
  218. )
  219. class WifiOffPulse(Icon):
  220. """A wifi_off icon with an animated opacity pulse."""
  221. @classmethod
  222. def create(cls, *children, **props) -> Icon:
  223. """Create a wifi_off icon with an animated opacity pulse.
  224. Args:
  225. *children: The children of the component.
  226. **props: The properties of the component.
  227. Returns:
  228. The icon component with default props applied.
  229. """
  230. pulse_var = Var(_js_expr="pulse")
  231. return super().create(
  232. "wifi_off",
  233. color=props.pop("color", "crimson"),
  234. size=props.pop("size", 32),
  235. z_index=props.pop("z_index", 9999),
  236. position=props.pop("position", "fixed"),
  237. bottom=props.pop("bottom", "33px"),
  238. right=props.pop("right", "33px"),
  239. animation=LiteralVar.create(f"{pulse_var} 1s infinite"),
  240. **props,
  241. )
  242. def add_imports(self) -> dict[str, str | ImportVar | list[str | ImportVar]]:
  243. """Add imports for the WifiOffPulse component.
  244. Returns:
  245. The import dict.
  246. """
  247. return {"@emotion/react": [ImportVar(tag="keyframes")]}
  248. def _get_custom_code(self) -> str | None:
  249. return """
  250. const pulse = keyframes`
  251. 0% {
  252. opacity: 0;
  253. }
  254. 100% {
  255. opacity: 1;
  256. }
  257. `
  258. """
  259. class ConnectionPulser(Div):
  260. """A connection pulser component."""
  261. @classmethod
  262. def create(cls, **props) -> Component:
  263. """Create a connection pulser component.
  264. Args:
  265. **props: The properties of the component.
  266. Returns:
  267. The connection pulser component.
  268. """
  269. return super().create(
  270. cond(
  271. has_connection_errors,
  272. WifiOffPulse.create(**props),
  273. ),
  274. title=f"Connection Error: {connection_error}",
  275. position="fixed",
  276. width="100vw",
  277. height="0",
  278. )
  279. class BackendDisabled(Div):
  280. """A component that displays a message when the backend is disabled."""
  281. @classmethod
  282. def create(cls, **props) -> Component:
  283. """Create a backend disabled component.
  284. Args:
  285. **props: The properties of the component.
  286. Returns:
  287. The backend disabled component.
  288. """
  289. import reflex as rx
  290. is_backend_disabled = Var(
  291. "backendDisabled",
  292. _var_type=bool,
  293. _var_data=VarData(
  294. hooks={
  295. "const [backendDisabled, setBackendDisabled] = useState(false);": None,
  296. "useEffect(() => { setBackendDisabled(isBackendDisabled()); }, []);": None,
  297. },
  298. imports={
  299. f"$/{constants.Dirs.STATE_PATH}": [
  300. ImportVar(tag="isBackendDisabled")
  301. ],
  302. },
  303. ),
  304. )
  305. return super().create(
  306. rx.cond(
  307. is_backend_disabled,
  308. rx.box(
  309. rx.el.link(
  310. rel="preconnect",
  311. href="https://fonts.googleapis.com",
  312. ),
  313. rx.el.link(
  314. rel="preconnect",
  315. href="https://fonts.gstatic.com",
  316. crossorigin="",
  317. ),
  318. rx.el.link(
  319. href="https://fonts.googleapis.com/css2?family=Instrument+Sans:ital,wght@0,500;0,600&display=swap",
  320. rel="stylesheet",
  321. ),
  322. rx.box(
  323. rx.vstack(
  324. rx.text(
  325. "This app is paused",
  326. font_size="1.5rem",
  327. font_weight="600",
  328. line_height="1.25rem",
  329. letter_spacing="-0.0375rem",
  330. ),
  331. rx.hstack(
  332. rx.el.svg(
  333. rx.el.path(
  334. d="M6.90816 1.34341C7.61776 1.10786 8.38256 1.10786 9.09216 1.34341C9.7989 1.57799 10.3538 2.13435 10.9112 2.91605C11.4668 3.69515 12.0807 4.78145 12.872 6.18175L12.9031 6.23672C13.6946 7.63721 14.3085 8.72348 14.6911 9.60441C15.0755 10.4896 15.267 11.2539 15.1142 11.9881C14.9604 12.7275 14.5811 13.3997 14.0287 13.9079C13.4776 14.4147 12.7273 14.6286 11.7826 14.7313C10.8432 14.8334 9.6143 14.8334 8.0327 14.8334H7.9677C6.38604 14.8334 5.15719 14.8334 4.21778 14.7313C3.27301 14.6286 2.52269 14.4147 1.97164 13.9079C1.41924 13.3997 1.03995 12.7275 0.88613 11.9881C0.733363 11.2539 0.92483 10.4896 1.30926 9.60441C1.69184 8.72348 2.30573 7.63721 3.09722 6.23671L3.12828 6.18175C3.91964 4.78146 4.53355 3.69515 5.08914 2.91605C5.64658 2.13435 6.20146 1.57799 6.90816 1.34341ZM7.3335 11.3334C7.3335 10.9652 7.63063 10.6667 7.99716 10.6667H8.00316C8.3697 10.6667 8.66683 10.9652 8.66683 11.3334C8.66683 11.7016 8.3697 12.0001 8.00316 12.0001H7.99716C7.63063 12.0001 7.3335 11.7016 7.3335 11.3334ZM7.3335 8.66675C7.3335 9.03495 7.63196 9.33341 8.00016 9.33341C8.36836 9.33341 8.66683 9.03495 8.66683 8.66675V6.00008C8.66683 5.63189 8.36836 5.33341 8.00016 5.33341C7.63196 5.33341 7.3335 5.63189 7.3335 6.00008V8.66675Z",
  335. fill_rule="evenodd",
  336. clip_rule="evenodd",
  337. fill=rx.color("amber", 11),
  338. ),
  339. width="16",
  340. height="16",
  341. viewBox="0 0 16 16",
  342. fill="none",
  343. xmlns="http://www.w3.org/2000/svg",
  344. margin_top="0.125rem",
  345. flex_shrink="0",
  346. ),
  347. rx.text(
  348. "If you are the owner of this app, visit ",
  349. rx.link(
  350. "Reflex Cloud",
  351. color=rx.color("amber", 11),
  352. underline="always",
  353. _hover={
  354. "color": rx.color("amber", 11),
  355. "text_decoration_color": rx.color(
  356. "amber", 11
  357. ),
  358. },
  359. text_decoration_color=rx.color("amber", 10),
  360. href="https://cloud.reflex.dev/",
  361. font_weight="600",
  362. is_external=True,
  363. ),
  364. " for more information on how to resume your app.",
  365. font_size="0.875rem",
  366. font_weight="500",
  367. line_height="1.25rem",
  368. letter_spacing="-0.01094rem",
  369. color=rx.color("amber", 11),
  370. ),
  371. align="start",
  372. gap="0.625rem",
  373. border_radius="0.75rem",
  374. border_width="1px",
  375. border_color=rx.color("amber", 5),
  376. background_color=rx.color("amber", 3),
  377. padding="0.625rem",
  378. ),
  379. rx.link(
  380. rx.el.button(
  381. "Resume app",
  382. color="rgba(252, 252, 253, 1)",
  383. font_size="0.875rem",
  384. font_weight="600",
  385. line_height="1.25rem",
  386. letter_spacing="-0.01094rem",
  387. height="2.5rem",
  388. padding="0rem 0.75rem",
  389. width="100%",
  390. border_radius="0.75rem",
  391. background=f"linear-gradient(180deg, {rx.color('violet', 9)} 0%, {rx.color('violet', 10)} 100%)",
  392. _hover={
  393. "background": f"linear-gradient(180deg, {rx.color('violet', 10)} 0%, {rx.color('violet', 10)} 100%)",
  394. },
  395. ),
  396. width="100%",
  397. underline="none",
  398. href="https://cloud.reflex.dev/",
  399. is_external=True,
  400. ),
  401. gap="1rem",
  402. ),
  403. font_family='"Instrument Sans", "Helvetica", "Arial", sans-serif',
  404. position="fixed",
  405. top="50%",
  406. left="50%",
  407. transform="translate(-50%, -50%)",
  408. width="60ch",
  409. max_width="90vw",
  410. border_radius="0.75rem",
  411. border_width="1px",
  412. border_color=rx.color("slate", 4),
  413. padding="1.5rem",
  414. background_color=rx.color("slate", 1),
  415. box_shadow="0px 2px 5px 0px light-dark(rgba(28, 32, 36, 0.03), rgba(0, 0, 0, 0.00))",
  416. ),
  417. position="fixed",
  418. z_index=9999,
  419. backdrop_filter="grayscale(1) blur(5px)",
  420. width="100dvw",
  421. height="100dvh",
  422. ),
  423. )
  424. )
  425. connection_banner = ConnectionBanner.create
  426. connection_modal = ConnectionModal.create
  427. connection_toaster = ConnectionToaster.create
  428. connection_pulser = ConnectionPulser.create
  429. backend_disabled = BackendDisabled.create