From a7fcc2f3efb66d24776ffbf8fb25831278ad1b83 Mon Sep 17 00:00:00 2001 From: tunonalves Date: Wed, 13 Nov 2024 20:00:28 -0300 Subject: [PATCH] first commit --- .gitignore | 176 ++++ .web/.gitignore | 39 + .web/bunfig.toml | 3 + .../radix_themes_color_mode_provider.js | 53 + .web/components/shiki/code.js | 34 + .web/jsconfig.json | 9 + .web/next.config.js | 1 + .web/package.json | 28 + .web/postcss.config.js | 7 + .web/reflex.json | 1 + .web/styles/tailwind.css | 6 + .web/utils/client_side_routing.js | 41 + .web/utils/helpers/dataeditor.js | 69 ++ .web/utils/helpers/debounce.js | 17 + .web/utils/helpers/paste.js | 59 ++ .web/utils/helpers/range.js | 43 + .web/utils/helpers/throttle.js | 22 + .web/utils/state.js | 923 ++++++++++++++++++ assets/favicon.ico | Bin 0 -> 4286 bytes requirements.txt | 1 + rxconfig.py | 5 + webreflex/__init__.py | 0 webreflex/webreflex.py | 39 + 23 files changed, 1576 insertions(+) create mode 100644 .gitignore create mode 100644 .web/.gitignore create mode 100644 .web/bunfig.toml create mode 100644 .web/components/reflex/radix_themes_color_mode_provider.js create mode 100644 .web/components/shiki/code.js create mode 100644 .web/jsconfig.json create mode 100644 .web/next.config.js create mode 100644 .web/package.json create mode 100644 .web/postcss.config.js create mode 100644 .web/reflex.json create mode 100644 .web/styles/tailwind.css create mode 100644 .web/utils/client_side_routing.js create mode 100644 .web/utils/helpers/dataeditor.js create mode 100644 .web/utils/helpers/debounce.js create mode 100644 .web/utils/helpers/paste.js create mode 100644 .web/utils/helpers/range.js create mode 100644 .web/utils/helpers/throttle.js create mode 100644 .web/utils/state.js create mode 100644 assets/favicon.ico create mode 100644 requirements.txt create mode 100644 rxconfig.py create mode 100644 webreflex/__init__.py create mode 100644 webreflex/webreflex.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42bec68 --- /dev/null +++ b/.gitignore @@ -0,0 +1,176 @@ +# Created by https://www.toptal.com/developers/gitignore/api/django +# Edit at https://www.toptal.com/developers/gitignore?templates=django + +### Django ### +*.log +*.pot +*.pyc +__pycache__/ +local_settings.py +db.sqlite3 +db.sqlite3-journal +media + +# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ +# in your Git repository. Update and uncomment the following line accordingly. +# /staticfiles/ + +### Django.Python Stack ### +# Byte-compiled / optimized / DLL files +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo + +# Django stuff: + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# End of https://www.toptal.com/developers/gitignore/api/django + +.DS_Store \ No newline at end of file diff --git a/.web/.gitignore b/.web/.gitignore new file mode 100644 index 0000000..534bc86 --- /dev/null +++ b/.web/.gitignore @@ -0,0 +1,39 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +/_static + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel + +# DS_Store +.DS_Store \ No newline at end of file diff --git a/.web/bunfig.toml b/.web/bunfig.toml new file mode 100644 index 0000000..123823b --- /dev/null +++ b/.web/bunfig.toml @@ -0,0 +1,3 @@ + +[install] +registry = "https://registry.npmjs.org" diff --git a/.web/components/reflex/radix_themes_color_mode_provider.js b/.web/components/reflex/radix_themes_color_mode_provider.js new file mode 100644 index 0000000..dd7886c --- /dev/null +++ b/.web/components/reflex/radix_themes_color_mode_provider.js @@ -0,0 +1,53 @@ +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; +import { + ColorModeContext, + defaultColorMode, + isDevMode, + lastCompiledTimeStamp, +} from "$/utils/context.js"; + +export default function RadixThemesColorModeProvider({ children }) { + const { theme, resolvedTheme, setTheme } = useTheme(); + const [rawColorMode, setRawColorMode] = useState(defaultColorMode); + const [resolvedColorMode, setResolvedColorMode] = useState("dark"); + + useEffect(() => { + if (isDevMode) { + const lastCompiledTimeInLocalStorage = + localStorage.getItem("last_compiled_time"); + if ( + lastCompiledTimeInLocalStorage && + lastCompiledTimeInLocalStorage !== lastCompiledTimeStamp + ) { + // on app startup, make sure the application color mode is persisted correctly. + setTheme(defaultColorMode); + localStorage.setItem("last_compiled_time", lastCompiledTimeStamp); + return; + } + } + setRawColorMode(theme); + setResolvedColorMode(resolvedTheme); + }, [theme, resolvedTheme]); + + const toggleColorMode = () => { + setTheme(resolvedTheme === "light" ? "dark" : "light"); + }; + const setColorMode = (mode) => { + const allowedModes = ["light", "dark", "system"]; + if (!allowedModes.includes(mode)) { + console.error( + `Invalid color mode "${mode}". Defaulting to "${defaultColorMode}".` + ); + mode = defaultColorMode; + } + setTheme(mode); + }; + return ( + + {children} + + ); +} diff --git a/.web/components/shiki/code.js b/.web/components/shiki/code.js new file mode 100644 index 0000000..22b3693 --- /dev/null +++ b/.web/components/shiki/code.js @@ -0,0 +1,34 @@ +import { useEffect, useState } from "react" +import { codeToHtml} from "shiki" + +/** + * Code component that uses Shiki to convert code to HTML and render it. + * + * @param code - The code to be highlighted. + * @param theme - The theme to be used for highlighting. + * @param language - The language of the code. + * @param transformers - The transformers to be applied to the code. + * @param decorations - The decorations to be applied to the code. + * @param divProps - Additional properties to be passed to the div element. + * @returns The rendered code block. + */ +export function Code ({code, theme, language, transformers, decorations, ...divProps}) { + const [codeResult, setCodeResult] = useState("") + useEffect(() => { + async function fetchCode() { + const result = await codeToHtml(code, { + lang: language, + theme, + transformers, + decorations + }); + setCodeResult(result); + } + fetchCode(); + }, [code, language, theme, transformers, decorations] + + ) + return ( +
+ ) +} diff --git a/.web/jsconfig.json b/.web/jsconfig.json new file mode 100644 index 0000000..3fcb35b --- /dev/null +++ b/.web/jsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "$/*": ["*"], + "@/*": ["public/*"] + } + } +} diff --git a/.web/next.config.js b/.web/next.config.js new file mode 100644 index 0000000..4ed8513 --- /dev/null +++ b/.web/next.config.js @@ -0,0 +1 @@ +module.exports = {basePath: "", compress: true, reactStrictMode: true, trailingSlash: true, staticPageGenerationTimeout: 60}; \ No newline at end of file diff --git a/.web/package.json b/.web/package.json new file mode 100644 index 0000000..aed36f5 --- /dev/null +++ b/.web/package.json @@ -0,0 +1,28 @@ +{ + "name": "reflex", + "scripts": { + "dev": "next dev", + "export": "next build", + "export-sitemap": "next build && next-sitemap", + "prod": "next start" + }, + "dependencies": { + "@babel/standalone": "7.26.0", + "@emotion/react": "11.13.3", + "axios": "1.7.7", + "json5": "2.2.3", + "next": "14.2.16", + "next-sitemap": "4.2.3", + "next-themes": "0.3.0", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-focus-lock": "2.13.2", + "socket.io-client": "4.8.1", + "universal-cookie": "7.2.1" + }, + "devDependencies": { + "autoprefixer": "10.4.20", + "postcss": "8.4.47", + "postcss-import": "16.1.0" + } +} \ No newline at end of file diff --git a/.web/postcss.config.js b/.web/postcss.config.js new file mode 100644 index 0000000..1e71298 --- /dev/null +++ b/.web/postcss.config.js @@ -0,0 +1,7 @@ +module.exports = { + plugins: { + "postcss-import": {}, + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/.web/reflex.json b/.web/reflex.json new file mode 100644 index 0000000..6ef1776 --- /dev/null +++ b/.web/reflex.json @@ -0,0 +1 @@ +{"version": "0.6.5", "project_hash": 41797754355572169224845275497637032181} \ No newline at end of file diff --git a/.web/styles/tailwind.css b/.web/styles/tailwind.css new file mode 100644 index 0000000..e1c3837 --- /dev/null +++ b/.web/styles/tailwind.css @@ -0,0 +1,6 @@ +@import "tailwindcss/base"; + +@import "@radix-ui/themes/styles.css"; + +@tailwind components; +@tailwind utilities; diff --git a/.web/utils/client_side_routing.js b/.web/utils/client_side_routing.js new file mode 100644 index 0000000..1718c8e --- /dev/null +++ b/.web/utils/client_side_routing.js @@ -0,0 +1,41 @@ +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/router"; + +/** + * React hook for use in /404 page to enable client-side routing. + * + * Uses the next/router to redirect to the provided URL when loading + * the 404 page (for example as a fallback in static hosting situations). + * + * @returns {boolean} routeNotFound - true if the current route is an actual 404 + */ +export const useClientSideRouting = () => { + const [routeNotFound, setRouteNotFound] = useState(false) + const didRedirect = useRef(false) + const router = useRouter() + useEffect(() => { + if ( + router.isReady && + !didRedirect.current // have not tried redirecting yet + ) { + didRedirect.current = true // never redirect twice to avoid "Hard Navigate" error + // attempt to redirect to the route in the browser address bar once + router.replace({ + pathname: window.location.pathname, + query: window.location.search.slice(1), + }).then(()=>{ + // Check if the current route is /404 + if (router.pathname === '/404') { + setRouteNotFound(true); // Mark as an actual 404 + } + }) + .catch((e) => { + setRouteNotFound(true) // navigation failed, so this is a real 404 + }) + } + }, [router.isReady]); + + // Return the reactive bool, to avoid flashing 404 page until we know for sure + // the route is not found. + return routeNotFound +} \ No newline at end of file diff --git a/.web/utils/helpers/dataeditor.js b/.web/utils/helpers/dataeditor.js new file mode 100644 index 0000000..9ff3682 --- /dev/null +++ b/.web/utils/helpers/dataeditor.js @@ -0,0 +1,69 @@ +import { GridCellKind } from "@glideapps/glide-data-grid"; + +export function getDEColumn(columns, col) { + let c = columns[col]; + c.pos = col; + return c; +} + +export function getDERow(data, row) { + return data[row]; +} + +export function locateCell(row, column) { + if (Array.isArray(row)) { + return row[column.pos]; + } else { + return row[column.id]; + } +} + +export function formatCell(value, column) { + const editable = column.editable ?? true; + switch (column.type) { + case "int": + case "float": + return { + kind: GridCellKind.Number, + data: value, + displayData: value + "", + readonly: !editable, + allowOverlay: editable, + }; + case "datetime": + // value = moment format? + case "str": + return { + kind: GridCellKind.Text, + data: value, + displayData: value, + readonly: !editable, + allowOverlay: editable, + }; + case "bool": + return { + kind: GridCellKind.Boolean, + data: value, + readonly: !editable, + }; + default: + console.log( + "Warning: column.type is undefined for column.title=" + column.title + ); + return { + kind: GridCellKind.Text, + data: value, + displayData: column.type, + }; + } +} + +export function formatDataEditorCells(col, row, columns, data) { + if (row < data.length && col < columns.length) { + const column = getDEColumn(columns, col); + const rowData = getDERow(data, row); + const cellData = locateCell(rowData, column); + return formatCell(cellData, column); + } + return { kind: GridCellKind.Loading }; +} diff --git a/.web/utils/helpers/debounce.js b/.web/utils/helpers/debounce.js new file mode 100644 index 0000000..465baae --- /dev/null +++ b/.web/utils/helpers/debounce.js @@ -0,0 +1,17 @@ +const debounce_timeout_id = {}; + +/** + * Generic debounce helper + * + * @param {string} name - the name of the event to debounce + * @param {function} func - the function to call after debouncing + * @param {number} delay - the time in milliseconds to wait before calling the function + */ +export default function debounce(name, func, delay) { + const key = `${name}__${delay}`; + clearTimeout(debounce_timeout_id[key]); + debounce_timeout_id[key] = setTimeout(() => { + func(); + delete debounce_timeout_id[key]; + }, delay); +} diff --git a/.web/utils/helpers/paste.js b/.web/utils/helpers/paste.js new file mode 100644 index 0000000..f30fe94 --- /dev/null +++ b/.web/utils/helpers/paste.js @@ -0,0 +1,59 @@ +import { useEffect } from "react"; + +const handle_paste_data = (clipboardData) => + new Promise((resolve, reject) => { + const pasted_data = []; + const n_items = clipboardData.items.length; + const extract_data = (item) => { + const type = item.type; + if (item.kind === "string") { + item.getAsString((data) => { + pasted_data.push([type, data]); + if (pasted_data.length === n_items) { + resolve(pasted_data); + } + }); + } else if (item.kind === "file") { + const file = item.getAsFile(); + const reader = new FileReader(); + reader.onload = (e) => { + pasted_data.push([type, e.target.result]); + if (pasted_data.length === n_items) { + resolve(pasted_data); + } + }; + if (type.indexOf("text/") === 0) { + reader.readAsText(file); + } else { + reader.readAsDataURL(file); + } + } + }; + for (const item of clipboardData.items) { + extract_data(item); + } + }); + +export default function usePasteHandler(target_ids, event_actions, on_paste) { + return useEffect(() => { + const handle_paste = (_ev) => { + event_actions.preventDefault && _ev.preventDefault(); + event_actions.stopPropagation && _ev.stopPropagation(); + handle_paste_data(_ev.clipboardData).then(on_paste); + }; + const targets = target_ids + .map((id) => document.getElementById(id)) + .filter((element) => !!element); + if (target_ids.length === 0) { + targets.push(document); + } + targets.forEach((target) => + target.addEventListener("paste", handle_paste, false), + ); + return () => { + targets.forEach((target) => + target.removeEventListener("paste", handle_paste, false), + ); + }; + }); +} diff --git a/.web/utils/helpers/range.js b/.web/utils/helpers/range.js new file mode 100644 index 0000000..7d1aeda --- /dev/null +++ b/.web/utils/helpers/range.js @@ -0,0 +1,43 @@ +/** + * Simulate the python range() builtin function. + * inspired by https://dev.to/guyariely/using-python-range-in-javascript-337p + * + * If needed outside of an iterator context, use `Array.from(range(10))` or + * spread syntax `[...range(10)]` to get an array. + * + * @param {number} start: the start or end of the range. + * @param {number} stop: the end of the range. + * @param {number} step: the step of the range. + * @returns {object} an object with a Symbol.iterator method over the range + */ +export default function range(start, stop, step) { + return { + [Symbol.iterator]() { + if (stop === undefined) { + stop = start; + start = 0; + } + if (step === undefined) { + step = 1; + } + + let i = start - step; + + return { + next() { + i += step; + if ((step > 0 && i < stop) || (step < 0 && i > stop)) { + return { + value: i, + done: false, + }; + } + return { + value: undefined, + done: true, + }; + }, + }; + }, + }; + } \ No newline at end of file diff --git a/.web/utils/helpers/throttle.js b/.web/utils/helpers/throttle.js new file mode 100644 index 0000000..771937b --- /dev/null +++ b/.web/utils/helpers/throttle.js @@ -0,0 +1,22 @@ +const in_throttle = {}; + +/** + * Generic throttle helper + * + * @param {string} name - the name of the event to throttle + * @param {number} limit - time in milliseconds between events + * @returns true if the event is allowed to execute, false if it is throttled + */ +export default function throttle(name, limit) { + const key = `${name}__${limit}`; + if (!in_throttle[key]) { + in_throttle[key] = true; + + setTimeout(() => { + delete in_throttle[key]; + }, limit); + // function was not throttled, so allow execution + return true; + } + return false; +} diff --git a/.web/utils/state.js b/.web/utils/state.js new file mode 100644 index 0000000..3899ddc --- /dev/null +++ b/.web/utils/state.js @@ -0,0 +1,923 @@ +// State management for Reflex web apps. +import axios from "axios"; +import io from "socket.io-client"; +import JSON5 from "json5"; +import env from "$/env.json"; +import Cookies from "universal-cookie"; +import { useEffect, useRef, useState } from "react"; +import Router, { useRouter } from "next/router"; +import { + initialEvents, + initialState, + onLoadInternalEvent, + state_name, + exception_state_name, +} from "$/utils/context.js"; +import debounce from "$/utils/helpers/debounce"; +import throttle from "$/utils/helpers/throttle"; + +// Endpoint URLs. +const EVENTURL = env.EVENT; +const UPLOADURL = env.UPLOAD; + +// These hostnames indicate that the backend and frontend are reachable via the same domain. +const SAME_DOMAIN_HOSTNAMES = ["localhost", "0.0.0.0", "::", "0:0:0:0:0:0:0:0"]; + +// Global variable to hold the token. +let token; + +// Key for the token in the session storage. +const TOKEN_KEY = "token"; + +// create cookie instance +const cookies = new Cookies(); + +// Dictionary holding component references. +export const refs = {}; + +// Flag ensures that only one event is processing on the backend concurrently. +let event_processing = false; +// Array holding pending events to be processed. +const event_queue = []; + +// Pending upload promises, by id +const upload_controllers = {}; + +/** + * Generate a UUID (Used for session tokens). + * Taken from: https://stackoverflow.com/questions/105034/how-do-i-create-a-guid-uuid + * @returns A UUID. + */ +export const generateUUID = () => { + let d = new Date().getTime(), + d2 = (performance && performance.now && performance.now() * 1000) || 0; + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + let r = Math.random() * 16; + if (d > 0) { + r = (d + r) % 16 | 0; + d = Math.floor(d / 16); + } else { + r = (d2 + r) % 16 | 0; + d2 = Math.floor(d2 / 16); + } + return (c == "x" ? r : (r & 0x7) | 0x8).toString(16); + }); +}; + +/** + * Get the token for the current session. + * @returns The token. + */ +export const getToken = () => { + if (token) { + return token; + } + if (typeof window !== "undefined") { + if (!window.sessionStorage.getItem(TOKEN_KEY)) { + window.sessionStorage.setItem(TOKEN_KEY, generateUUID()); + } + token = window.sessionStorage.getItem(TOKEN_KEY); + } + return token; +}; + +/** + * Get the URL for the backend server + * @param url_str The URL string to parse. + * @returns The given URL modified to point to the actual backend server. + */ +export const getBackendURL = (url_str) => { + // Get backend URL object from the endpoint. + const endpoint = new URL(url_str); + if ( + typeof window !== "undefined" && + SAME_DOMAIN_HOSTNAMES.includes(endpoint.hostname) + ) { + // Use the frontend domain to access the backend + const frontend_hostname = window.location.hostname; + endpoint.hostname = frontend_hostname; + if (window.location.protocol === "https:") { + if (endpoint.protocol === "ws:") { + endpoint.protocol = "wss:"; + } else if (endpoint.protocol === "http:") { + endpoint.protocol = "https:"; + } + endpoint.port = ""; // Assume websocket is on https port via load balancer. + } + } + return endpoint; +}; + +/** + * Determine if any event in the event queue is stateful. + * + * @returns True if there's any event that requires state and False if none of them do. + */ +export const isStateful = () => { + if (event_queue.length === 0) { + return false; + } + return event_queue.some((event) => event.name.startsWith("reflex___state")); +}; + +/** + * Apply a delta to the state. + * @param state The state to apply the delta to. + * @param delta The delta to apply. + */ +export const applyDelta = (state, delta) => { + return { ...state, ...delta }; +}; + +/** + * Evaluate a dynamic component. + * @param component The component to evaluate. + * @returns The evaluated component. + */ +export const evalReactComponent = async (component) => { + if (!window.React && window.__reflex) { + window.React = window.__reflex.react; + } + const encodedJs = encodeURIComponent(component); + const dataUri = "data:text/javascript;charset=utf-8," + encodedJs; + const module = await eval(`import(dataUri)`); + return module.default; +}; + +/** + * Only Queue and process events when websocket connection exists. + * @param event The event to queue. + * @param socket The socket object to send the event on. + * + * @returns Adds event to queue and processes it if websocket exits, does nothing otherwise. + */ +export const queueEventIfSocketExists = async (events, socket) => { + if (!socket) { + return; + } + await queueEvents(events, socket); +}; + +/** + * Handle frontend event or send the event to the backend via Websocket. + * @param event The event to send. + * @param socket The socket object to send the event on. + * + * @returns True if the event was sent, false if it was handled locally. + */ +export const applyEvent = async (event, socket) => { + // Handle special events + if (event.name == "_redirect") { + if (event.payload.external) { + window.open(event.payload.path, "_blank"); + } else if (event.payload.replace) { + Router.replace(event.payload.path); + } else { + Router.push(event.payload.path); + } + return false; + } + + if (event.name == "_remove_cookie") { + cookies.remove(event.payload.key, { ...event.payload.options }); + queueEventIfSocketExists(initialEvents(), socket); + return false; + } + + if (event.name == "_clear_local_storage") { + localStorage.clear(); + queueEventIfSocketExists(initialEvents(), socket); + return false; + } + + if (event.name == "_remove_local_storage") { + localStorage.removeItem(event.payload.key); + queueEventIfSocketExists(initialEvents(), socket); + return false; + } + + if (event.name == "_clear_session_storage") { + sessionStorage.clear(); + queueEvents(initialEvents(), socket); + return false; + } + + if (event.name == "_remove_session_storage") { + sessionStorage.removeItem(event.payload.key); + queueEvents(initialEvents(), socket); + return false; + } + + if (event.name == "_download") { + const a = document.createElement("a"); + a.hidden = true; + // Special case when linking to uploaded files + a.href = event.payload.url.replace( + "${getBackendURL(env.UPLOAD)}", + getBackendURL(env.UPLOAD) + ); + a.download = event.payload.filename; + a.click(); + a.remove(); + return false; + } + + if (event.name == "_set_focus") { + const ref = + event.payload.ref in refs ? refs[event.payload.ref] : event.payload.ref; + ref.current.focus(); + return false; + } + + if (event.name == "_set_value") { + const ref = + event.payload.ref in refs ? refs[event.payload.ref] : event.payload.ref; + if (ref.current) { + ref.current.value = event.payload.value; + } + return false; + } + + if ( + event.name == "_call_function" && + typeof event.payload.function !== "string" + ) { + try { + const eval_result = event.payload.function(); + if (event.payload.callback) { + if (!!eval_result && typeof eval_result.then === "function") { + event.payload.callback(await eval_result); + } else { + event.payload.callback(eval_result); + } + } + } catch (e) { + console.log("_call_function", e); + if (window && window?.onerror) { + window.onerror(e.message, null, null, null, e); + } + } + return false; + } + + if (event.name == "_call_script" || event.name == "_call_function") { + try { + const eval_result = + event.name == "_call_script" + ? eval(event.payload.javascript_code) + : eval(event.payload.function)(); + + if (event.payload.callback) { + if (!!eval_result && typeof eval_result.then === "function") { + eval(event.payload.callback)(await eval_result); + } else { + eval(event.payload.callback)(eval_result); + } + } + } catch (e) { + console.log("_call_script", e); + if (window && window?.onerror) { + window.onerror(e.message, null, null, null, e); + } + } + return false; + } + + // Update token and router data (if missing). + event.token = getToken(); + if ( + event.router_data === undefined || + Object.keys(event.router_data).length === 0 + ) { + event.router_data = (({ pathname, query, asPath }) => ({ + pathname, + query, + asPath, + }))(Router); + } + + // Send the event to the server. + if (socket) { + socket.emit( + "event", + JSON.stringify(event, (k, v) => (v === undefined ? null : v)) + ); + return true; + } + + return false; +}; + +/** + * Send an event to the server via REST. + * @param event The current event. + * @param socket The socket object to send the response event(s) on. + * + * @returns Whether the event was sent. + */ +export const applyRestEvent = async (event, socket) => { + let eventSent = false; + if (event.handler === "uploadFiles") { + if (event.payload.files === undefined || event.payload.files.length === 0) { + // Submit the event over the websocket to trigger the event handler. + return await applyEvent(Event(event.name), socket); + } + + // Start upload, but do not wait for it, which would block other events. + uploadFiles( + event.name, + event.payload.files, + event.payload.upload_id, + event.payload.on_upload_progress, + socket + ); + return false; + } + return eventSent; +}; + +/** + * Queue events to be processed and trigger processing of queue. + * @param events Array of events to queue. + * @param socket The socket object to send the event on. + */ +export const queueEvents = async (events, socket) => { + event_queue.push(...events); + await processEvent(socket.current); +}; + +/** + * Process an event off the event queue. + * @param socket The socket object to send the event on. + */ +export const processEvent = async (socket) => { + // Only proceed if the socket is up and no event in the queue uses state, otherwise we throw the event into the void + if (!socket && isStateful()) { + return; + } + + // Only proceed if we're not already processing an event. + if (event_queue.length === 0 || event_processing) { + return; + } + + // Set processing to true to block other events from being processed. + event_processing = true; + + // Apply the next event in the queue. + const event = event_queue.shift(); + + let eventSent = false; + // Process events with handlers via REST and all others via websockets. + if (event.handler) { + eventSent = await applyRestEvent(event, socket); + } else { + eventSent = await applyEvent(event, socket); + } + // If no event was sent, set processing to false. + if (!eventSent) { + event_processing = false; + // recursively call processEvent to drain the queue, since there is + // no state update to trigger the useEffect event loop. + await processEvent(socket); + } +}; + +/** + * Connect to a websocket and set the handlers. + * @param socket The socket object to connect. + * @param dispatch The function to queue state update + * @param transports The transports to use. + * @param setConnectErrors The function to update connection error value. + * @param client_storage The client storage object from context.js + */ +export const connect = async ( + socket, + dispatch, + transports, + setConnectErrors, + client_storage = {} +) => { + // Get backend URL object from the endpoint. + const endpoint = getBackendURL(EVENTURL); + + // Create the socket. + socket.current = io(endpoint.href, { + path: endpoint["pathname"], + transports: transports, + autoUnref: false, + }); + + function checkVisibility() { + if (document.visibilityState === "visible") { + if (!socket.current.connected) { + console.log("Socket is disconnected, attempting to reconnect "); + socket.current.connect(); + } else { + console.log("Socket is reconnected "); + } + } + } + + const pagehideHandler = (event) => { + if (event.persisted && socket.current?.connected) { + console.log("Disconnect backend before bfcache on navigation"); + socket.current.disconnect(); + } + }; + + // Once the socket is open, hydrate the page. + socket.current.on("connect", () => { + setConnectErrors([]); + window.addEventListener("pagehide", pagehideHandler); + }); + + socket.current.on("connect_error", (error) => { + setConnectErrors((connectErrors) => [connectErrors.slice(-9), error]); + }); + + // When the socket disconnects reset the event_processing flag + socket.current.on("disconnect", () => { + event_processing = false; + window.removeEventListener("pagehide", pagehideHandler); + }); + + // On each received message, queue the updates and events. + socket.current.on("event", async (message) => { + const update = JSON5.parse(message); + for (const substate in update.delta) { + dispatch[substate](update.delta[substate]); + } + applyClientStorageDelta(client_storage, update.delta); + event_processing = !update.final; + if (update.events) { + queueEvents(update.events, socket); + } + }); + + document.addEventListener("visibilitychange", checkVisibility); +}; + +/** + * Upload files to the server. + * + * @param state The state to apply the delta to. + * @param handler The handler to use. + * @param upload_id The upload id to use. + * @param on_upload_progress The function to call on upload progress. + * @param socket the websocket connection + * + * @returns The response from posting to the UPLOADURL endpoint. + */ +export const uploadFiles = async ( + handler, + files, + upload_id, + on_upload_progress, + socket +) => { + // return if there's no file to upload + if (files === undefined || files.length === 0) { + return false; + } + + if (upload_controllers[upload_id]) { + console.log("Upload already in progress for ", upload_id); + return false; + } + + let resp_idx = 0; + const eventHandler = (progressEvent) => { + // handle any delta / event streamed from the upload event handler + const chunks = progressEvent.event.target.responseText.trim().split("\n"); + chunks.slice(resp_idx).map((chunk) => { + try { + socket._callbacks.$event.map((f) => { + f(chunk); + }); + resp_idx += 1; + } catch (e) { + if (progressEvent.progress === 1) { + // Chunk may be incomplete, so only report errors when full response is available. + console.log("Error parsing chunk", chunk, e); + } + return; + } + }); + }; + + const controller = new AbortController(); + const config = { + headers: { + "Reflex-Client-Token": getToken(), + "Reflex-Event-Handler": handler, + }, + signal: controller.signal, + onDownloadProgress: eventHandler, + }; + if (on_upload_progress) { + config["onUploadProgress"] = on_upload_progress; + } + const formdata = new FormData(); + + // Add the token and handler to the file name. + files.forEach((file) => { + formdata.append("files", file, file.path || file.name); + }); + + // Send the file to the server. + upload_controllers[upload_id] = controller; + + try { + return await axios.post(getBackendURL(UPLOADURL), formdata, config); + } catch (error) { + if (error.response) { + // The request was made and the server responded with a status code + // that falls out of the range of 2xx + console.log(error.response.data); + } else if (error.request) { + // The request was made but no response was received + // `error.request` is an instance of XMLHttpRequest in the browser and an instance of + // http.ClientRequest in node.js + console.log(error.request); + } else { + // Something happened in setting up the request that triggered an Error + console.log(error.message); + } + return false; + } finally { + delete upload_controllers[upload_id]; + } +}; + +/** + * Create an event object. + * @param {string} name The name of the event. + * @param {Object.} payload The payload of the event. + * @param {Object.} event_actions The actions to take on the event. + * @param {string} handler The client handler to process event. + * @returns The event object. + */ +export const Event = ( + name, + payload = {}, + event_actions = {}, + handler = null +) => { + return { name, payload, handler, event_actions }; +}; + +/** + * Package client-side storage values as payload to send to the + * backend with the hydrate event + * @param client_storage The client storage object from context.js + * @returns payload dict of client storage values + */ +export const hydrateClientStorage = (client_storage) => { + const client_storage_values = {}; + if (client_storage.cookies) { + for (const state_key in client_storage.cookies) { + const cookie_options = client_storage.cookies[state_key]; + const cookie_name = cookie_options.name || state_key; + const cookie_value = cookies.get(cookie_name); + if (cookie_value !== undefined) { + client_storage_values[state_key] = cookies.get(cookie_name); + } + } + } + if (client_storage.local_storage && typeof window !== "undefined") { + for (const state_key in client_storage.local_storage) { + const options = client_storage.local_storage[state_key]; + const local_storage_value = localStorage.getItem( + options.name || state_key + ); + if (local_storage_value !== null) { + client_storage_values[state_key] = local_storage_value; + } + } + } + if (client_storage.session_storage && typeof window != "undefined") { + for (const state_key in client_storage.session_storage) { + const session_options = client_storage.session_storage[state_key]; + const session_storage_value = sessionStorage.getItem( + session_options.name || state_key + ); + if (session_storage_value != null) { + client_storage_values[state_key] = session_storage_value; + } + } + } + if ( + client_storage.cookies || + client_storage.local_storage || + client_storage.session_storage + ) { + return client_storage_values; + } + return {}; +}; + +/** + * Update client storage values based on backend state delta. + * @param client_storage The client storage object from context.js + * @param delta The state update from the backend + */ +const applyClientStorageDelta = (client_storage, delta) => { + // find the main state and check for is_hydrated + const unqualified_states = Object.keys(delta).filter( + (key) => key.split(".").length === 1 + ); + if (unqualified_states.length === 1) { + const main_state = delta[unqualified_states[0]]; + if (main_state.is_hydrated !== undefined && !main_state.is_hydrated) { + // skip if the state is not hydrated yet, since all client storage + // values are sent in the hydrate event + return; + } + } + // Save known client storage values to cookies and localStorage. + for (const substate in delta) { + for (const key in delta[substate]) { + const state_key = `${substate}.${key}`; + if (client_storage.cookies && state_key in client_storage.cookies) { + const cookie_options = { ...client_storage.cookies[state_key] }; + const cookie_name = cookie_options.name || state_key; + delete cookie_options.name; // name is not a valid cookie option + cookies.set(cookie_name, delta[substate][key], cookie_options); + } else if ( + client_storage.local_storage && + state_key in client_storage.local_storage && + typeof window !== "undefined" + ) { + const options = client_storage.local_storage[state_key]; + localStorage.setItem(options.name || state_key, delta[substate][key]); + } else if ( + client_storage.session_storage && + state_key in client_storage.session_storage && + typeof window !== "undefined" + ) { + const session_options = client_storage.session_storage[state_key]; + sessionStorage.setItem( + session_options.name || state_key, + delta[substate][key] + ); + } + } + } +}; + +/** + * Establish websocket event loop for a NextJS page. + * @param dispatch The reducer dispatch function to update state. + * @param initial_events The initial app events. + * @param client_storage The client storage object from context.js + * + * @returns [addEvents, connectErrors] - + * addEvents is used to queue an event, and + * connectErrors is an array of reactive js error from the websocket connection (or null if connected). + */ +export const useEventLoop = ( + dispatch, + initial_events = () => [], + client_storage = {} +) => { + const socket = useRef(null); + const router = useRouter(); + const [connectErrors, setConnectErrors] = useState([]); + + // Function to add new events to the event queue. + const addEvents = (events, args, event_actions) => { + if (!(args instanceof Array)) { + args = [args]; + } + + event_actions = events.reduce( + (acc, e) => ({ ...acc, ...e.event_actions }), + event_actions ?? {} + ); + + const _e = args.filter((o) => o?.preventDefault !== undefined)[0]; + + if (event_actions?.preventDefault && _e?.preventDefault) { + _e.preventDefault(); + } + if (event_actions?.stopPropagation && _e?.stopPropagation) { + _e.stopPropagation(); + } + const combined_name = events.map((e) => e.name).join("+++"); + if (event_actions?.throttle) { + // If throttle returns false, the events are not added to the queue. + if (!throttle(combined_name, event_actions.throttle)) { + return; + } + } + if (event_actions?.debounce) { + // If debounce is used, queue the events after some delay + debounce( + combined_name, + () => queueEvents(events, socket), + event_actions.debounce + ); + } else { + queueEvents(events, socket); + } + }; + + const sentHydrate = useRef(false); // Avoid double-hydrate due to React strict-mode + useEffect(() => { + if (router.isReady && !sentHydrate.current) { + const events = initial_events(); + addEvents( + events.map((e) => ({ + ...e, + router_data: (({ pathname, query, asPath }) => ({ + pathname, + query, + asPath, + }))(router), + })) + ); + sentHydrate.current = true; + } + }, [router.isReady]); + + // Handle frontend errors and send them to the backend via websocket. + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + window.onerror = function (msg, url, lineNo, columnNo, error) { + addEvents([ + Event(`${exception_state_name}.handle_frontend_exception`, { + stack: error.stack, + component_stack: "", + }), + ]); + return false; + }; + + //NOTE: Only works in Chrome v49+ + //https://github.com/mknichel/javascript-errors?tab=readme-ov-file#promise-rejection-events + window.onunhandledrejection = function (event) { + addEvents([ + Event(`${exception_state_name}.handle_frontend_exception`, { + stack: event.reason.stack, + component_stack: "", + }), + ]); + return false; + }; + }, []); + + // Main event loop. + useEffect(() => { + // Skip if the router is not ready. + if (!router.isReady) { + return; + } + // only use websockets if state is present + if (Object.keys(initialState).length > 1) { + // Initialize the websocket connection. + if (!socket.current) { + connect( + socket, + dispatch, + ["websocket", "polling"], + setConnectErrors, + client_storage + ); + } + (async () => { + // Process all outstanding events. + while (event_queue.length > 0 && !event_processing) { + await processEvent(socket.current); + } + })(); + } + }); + + // localStorage event handling + useEffect(() => { + const storage_to_state_map = {}; + + if (client_storage.local_storage && typeof window !== "undefined") { + for (const state_key in client_storage.local_storage) { + const options = client_storage.local_storage[state_key]; + if (options.sync) { + const local_storage_value_key = options.name || state_key; + storage_to_state_map[local_storage_value_key] = state_key; + } + } + } + + // e is StorageEvent + const handleStorage = (e) => { + if (storage_to_state_map[e.key]) { + const vars = {}; + vars[storage_to_state_map[e.key]] = e.newValue; + const event = Event( + `${state_name}.reflex___state____update_vars_internal_state.update_vars_internal`, + { vars: vars } + ); + addEvents([event], e); + } + }; + + window.addEventListener("storage", handleStorage); + return () => window.removeEventListener("storage", handleStorage); + }); + + // Route after the initial page hydration. + useEffect(() => { + const change_start = () => { + const main_state_dispatch = dispatch["reflex___state____state"]; + if (main_state_dispatch !== undefined) { + main_state_dispatch({ is_hydrated: false }); + } + }; + const change_complete = () => addEvents(onLoadInternalEvent()); + router.events.on("routeChangeStart", change_start); + router.events.on("routeChangeComplete", change_complete); + return () => { + router.events.off("routeChangeStart", change_start); + router.events.off("routeChangeComplete", change_complete); + }; + }, [router]); + + return [addEvents, connectErrors]; +}; + +/*** + * Check if a value is truthy in python. + * @param val The value to check. + * @returns True if the value is truthy, false otherwise. + */ +export const isTrue = (val) => { + if (Array.isArray(val)) return val.length > 0; + if (val === Object(val)) return Object.keys(val).length > 0; + return Boolean(val); +}; + +/** + * Get the value from a ref. + * @param ref The ref to get the value from. + * @returns The value. + */ +export const getRefValue = (ref) => { + if (!ref || !ref.current) { + return; + } + if (ref.current.type == "checkbox") { + return ref.current.checked; // chakra + } else if ( + ref.current.className?.includes("rt-CheckboxRoot") || + ref.current.className?.includes("rt-SwitchRoot") + ) { + return ref.current.ariaChecked == "true"; // radix + } else if (ref.current.className?.includes("rt-SliderRoot")) { + // find the actual slider + return ref.current.querySelector(".rt-SliderThumb")?.ariaValueNow; + } else { + //querySelector(":checked") is needed to get value from radio_group + return ( + ref.current.value || + (ref.current.querySelector && + ref.current.querySelector(":checked") && + ref.current.querySelector(":checked")?.value) + ); + } +}; + +/** + * Get the values from a ref array. + * @param refs The refs to get the values from. + * @returns The values array. + */ +export const getRefValues = (refs) => { + if (!refs) { + return; + } + // getAttribute is used by RangeSlider because it doesn't assign value + return refs.map((ref) => + ref.current + ? ref.current.value || ref.current.getAttribute("aria-valuenow") + : null + ); +}; + +/** + * Spread two arrays or two objects. + * @param first The first array or object. + * @param second The second array or object. + * @returns The final merged array or object. + */ +export const spreadArraysOrObjects = (first, second) => { + if (Array.isArray(first) && Array.isArray(second)) { + return [...first, ...second]; + } else if (typeof first === "object" && typeof second === "object") { + return { ...first, ...second }; + } else { + throw new Error("Both parameters must be either arrays or objects."); + } +}; diff --git a/assets/favicon.ico b/assets/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..166ae995eaa63fc96771410a758282dc30e925cf GIT binary patch literal 4286 zcmeHL>rYc>81ELdEe;}zmYd}cUgmJRfwjUwD1`#s5KZP>mMqza#Viv|_7|8f+0+bX zHuqusuw-7Ca`DTu#4U4^o2bjO#K>4%N?Wdi*wZ3Vx%~Ef4}D1`U_EMRg3u z#2#M|V>}}q-@IaO@{9R}d*u7f&~5HfxSkmHVcazU#i30H zAGxQ5Spe!j9`KuGqR@aExK`-}sH1jvqoIp3C7Vm)9Tu=UPE;j^esN~a6^a$ZILngo;^ zGLXl(ZFyY&U!li`6}y-hUQ99v?s`U4O!kgog74FPw-9g+V)qs!jFGEQyvBf><U|E2vRmx|+(VI~S=lT?@~C5pvZOd`x{Q_+3tG6H=gtdWcf z)+7-Zp=UqH^J4sk^>_G-Ufn-2Hz z2mN12|C{5}U`^eCQuFz=F%wp@}SzA1MHEaM^CtJs<{}Tzu$bx2orTKiedgmtVGM{ zdd#vX`&cuiec|My_KW;y{Ryz2kFu9}=~us6hvx1ZqQCk(d+>HP>ks>mmHCjjDh{pe zKQkKpk0SeDX#XMqf$}QV{z=xrN!mQczJAvud@;zFqaU1ocq==Py)qsa=8UKrt!J7r z{RsTo^rgtZo%$rak)DN*D)!(Y^$@yL6Nd=#eu&?unzhH8yq>v{gkt8xcG3S%H)-y_ zqQ1|v|JT$0R~Y}omg2Y+nDvR+K|kzR5i^fmKF>j~N;A35Vr`JWh4yRqKl#P|qlx?` z@|CmBiP}ysYO%m2{eBG6&ix5 zr#u((F2{vb=W4jNmTQh3M^F2o80T49?w>*rv0mt)-o1y!{hRk`E#UVPdna6jnz`rw dKpn)r^--YJZpr;bYU`N~>#v3X5BRU&{{=gv-{1fM literal 0 HcmV?d00001 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9e6544e --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +reflex==0.6.5 diff --git a/rxconfig.py b/rxconfig.py new file mode 100644 index 0000000..c9c97da --- /dev/null +++ b/rxconfig.py @@ -0,0 +1,5 @@ +import reflex as rx + +config = rx.Config( + app_name="webreflex", +) \ No newline at end of file diff --git a/webreflex/__init__.py b/webreflex/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/webreflex/webreflex.py b/webreflex/webreflex.py new file mode 100644 index 0000000..c0de6e4 --- /dev/null +++ b/webreflex/webreflex.py @@ -0,0 +1,39 @@ +"""Welcome to Reflex! This file outlines the steps to create a basic app.""" + +import reflex as rx + +from rxconfig import config + + +class State(rx.State): + """The app state.""" + + ... + + +def index() -> rx.Component: + # Welcome Page (Index) + return rx.container( + rx.color_mode.button(position="top-right"), + rx.vstack( + rx.heading("Welcome to Reflex!", size="9"), + rx.text( + "Get started by editing ", + rx.code(f"{config.app_name}/{config.app_name}.py"), + size="5", + ), + rx.link( + rx.button("Check out our docs!"), + href="https://reflex.dev/docs/getting-started/introduction/", + is_external=True, + ), + spacing="5", + justify="center", + min_height="85vh", + ), + rx.logo(), + ) + + +app = rx.App() +app.add_page(index)