From 1c255069e7248c1b1041062325cacd3598738740 Mon Sep 17 00:00:00 2001 From: Timothy Pidashev Date: Mon, 11 Mar 2024 08:54:21 -0700 Subject: [PATCH] Were rolling with it --- Caddyfile.dev | 16 + Makefile | 5 + compose.dev.yml | 13 +- src/about/.dockerignore | 3 + src/about/.web/.gitignore | 39 + .../reflex/chakra_color_mode_provider.js | 21 + .../radix_themes_color_mode_provider.js | 22 + src/about/.web/jsconfig.json | 8 + src/about/.web/next.config.js | 1 + src/about/.web/package.json | 25 + src/about/.web/postcss.config.js | 6 + src/about/.web/reflex.json | 1 + src/about/.web/styles/tailwind.css | 3 + src/about/.web/utils/client_side_routing.js | 36 + src/about/.web/utils/helpers/dataeditor.js | 69 ++ src/about/.web/utils/helpers/range.js | 43 ++ src/about/.web/utils/state.js | 728 ++++++++++++++++++ src/about/Dockerfile.dev | 54 ++ src/about/about/__init__.py | 0 src/about/about/about.py | 35 + src/about/assets/favicon.ico | Bin 0 -> 4286 bytes src/about/requirements.txt | 1 + src/about/rxconfig.py | 5 + src/landing/rxconfig.py | 2 +- 24 files changed, 1134 insertions(+), 2 deletions(-) create mode 100644 src/about/.dockerignore create mode 100644 src/about/.web/.gitignore create mode 100644 src/about/.web/components/reflex/chakra_color_mode_provider.js create mode 100644 src/about/.web/components/reflex/radix_themes_color_mode_provider.js create mode 100644 src/about/.web/jsconfig.json create mode 100644 src/about/.web/next.config.js create mode 100644 src/about/.web/package.json create mode 100644 src/about/.web/postcss.config.js create mode 100644 src/about/.web/reflex.json create mode 100644 src/about/.web/styles/tailwind.css create mode 100644 src/about/.web/utils/client_side_routing.js create mode 100644 src/about/.web/utils/helpers/dataeditor.js create mode 100644 src/about/.web/utils/helpers/range.js create mode 100644 src/about/.web/utils/state.js create mode 100644 src/about/Dockerfile.dev create mode 100644 src/about/about/__init__.py create mode 100644 src/about/about/about.py create mode 100644 src/about/assets/favicon.ico create mode 100644 src/about/requirements.txt create mode 100644 src/about/rxconfig.py diff --git a/Caddyfile.dev b/Caddyfile.dev index 7f50e52..2cff439 100644 --- a/Caddyfile.dev +++ b/Caddyfile.dev @@ -13,3 +13,19 @@ timmypidashev.localhost { reverse_proxy landing:8000 } } + +about.timmypidashev.localhost { + encode gzip + + reverse_proxy about:3000 + + @backend_routes { + path /_event/* + path /_upload + path /ping + } + + handle @backend_routes { + reverse_proxy about:8000 + } +} diff --git a/Makefile b/Makefile index 2dcee74..0f7ba7d 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,11 @@ CONTAINER_LANDING_VERSION := "v1.0.0" CONTAINER_LANDING_LOCATION := "src/landing" CONTAINER_LANDING_DESCRIPTION := "The landing page for my website." +CONTAINER_ABOUT_NAME := "about" +CONTAINER_ABOUT_VERSION := "v0.0.0" +CONTAINER_ABOUT_LOCATION := "src/about" +CONTAINER_ABOUT_DESCRIPTION := "The about page for my website." + .DEFAULT_GOAL := help .PHONY: run build push prune bump .SILENT: run build push prune bump diff --git a/compose.dev.yml b/compose.dev.yml index 37b438e..93289a5 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -12,6 +12,9 @@ services: restart: always networks: - caddy + depends_on: + - landing + - about landing: container_name: landing @@ -22,7 +25,15 @@ services: - ./src/landing/rxconfig.py:/app/rxconfig.py networks: - caddy - depends_on: + + about: + container_name: about + image: about:dev + volumes: + - ./src/about/about:/app/about + - ./src/about/assets:/app/assets + - ./src/about/rxconfig.py:/app/rxconfig.py + networks: - caddy networks: diff --git a/src/about/.dockerignore b/src/about/.dockerignore new file mode 100644 index 0000000..b289110 --- /dev/null +++ b/src/about/.dockerignore @@ -0,0 +1,3 @@ +.web +__pycache__/* +Dockerfile diff --git a/src/about/.web/.gitignore b/src/about/.web/.gitignore new file mode 100644 index 0000000..534bc86 --- /dev/null +++ b/src/about/.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/src/about/.web/components/reflex/chakra_color_mode_provider.js b/src/about/.web/components/reflex/chakra_color_mode_provider.js new file mode 100644 index 0000000..f897522 --- /dev/null +++ b/src/about/.web/components/reflex/chakra_color_mode_provider.js @@ -0,0 +1,21 @@ +import { useColorMode as chakraUseColorMode } from "@chakra-ui/react" +import { useTheme } from "next-themes" +import { useEffect } from "react" +import { ColorModeContext } from "/utils/context.js" + +export default function ChakraColorModeProvider({ children }) { + const {colorMode, toggleColorMode} = chakraUseColorMode() + const {theme, setTheme} = useTheme() + + useEffect(() => { + if (colorMode != theme) { + toggleColorMode() + } + }, [theme]) + + return ( + + {children} + + ) +} \ No newline at end of file diff --git a/src/about/.web/components/reflex/radix_themes_color_mode_provider.js b/src/about/.web/components/reflex/radix_themes_color_mode_provider.js new file mode 100644 index 0000000..e2dd563 --- /dev/null +++ b/src/about/.web/components/reflex/radix_themes_color_mode_provider.js @@ -0,0 +1,22 @@ +import { useTheme } from "next-themes" +import { useEffect, useState } from "react" +import { ColorModeContext, defaultColorMode } from "/utils/context.js" + + +export default function RadixThemesColorModeProvider({ children }) { + const {theme, setTheme} = useTheme() + const [colorMode, setColorMode] = useState(defaultColorMode) + + useEffect(() => { + setColorMode(theme) + }, [theme]) + + const toggleColorMode = () => { + setTheme(theme === "light" ? "dark" : "light") + } + return ( + + {children} + + ) + } \ No newline at end of file diff --git a/src/about/.web/jsconfig.json b/src/about/.web/jsconfig.json new file mode 100644 index 0000000..3c8a325 --- /dev/null +++ b/src/about/.web/jsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["public/*"] + } + } +} \ No newline at end of file diff --git a/src/about/.web/next.config.js b/src/about/.web/next.config.js new file mode 100644 index 0000000..42dad1d --- /dev/null +++ b/src/about/.web/next.config.js @@ -0,0 +1 @@ +module.exports = {basePath: "", compress: true, reactStrictMode: true, trailingSlash: true}; diff --git a/src/about/.web/package.json b/src/about/.web/package.json new file mode 100644 index 0000000..aab86df --- /dev/null +++ b/src/about/.web/package.json @@ -0,0 +1,25 @@ +{ + "name": "reflex", + "scripts": { + "dev": "next dev", + "export": "next build", + "export-sitemap": "next build && next-sitemap", + "prod": "next start" + }, + "dependencies": { + "@emotion/react": "11.11.1", + "axios": "1.6.0", + "json5": "2.2.3", + "next": "14.0.1", + "next-sitemap": "4.1.8", + "next-themes": "0.2.1", + "react": "18.2.0", + "react-dom": "18.2.0", + "socket.io-client": "4.6.1", + "universal-cookie": "4.0.4" + }, + "devDependencies": { + "autoprefixer": "10.4.14", + "postcss": "8.4.31" + } +} \ No newline at end of file diff --git a/src/about/.web/postcss.config.js b/src/about/.web/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/src/about/.web/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/src/about/.web/reflex.json b/src/about/.web/reflex.json new file mode 100644 index 0000000..7aecab7 --- /dev/null +++ b/src/about/.web/reflex.json @@ -0,0 +1 @@ +{"version": "0.4.3", "project_hash": 326586468133229768661018683044141625593} \ No newline at end of file diff --git a/src/about/.web/styles/tailwind.css b/src/about/.web/styles/tailwind.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/src/about/.web/styles/tailwind.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/src/about/.web/utils/client_side_routing.js b/src/about/.web/utils/client_side_routing.js new file mode 100644 index 0000000..75fb581 --- /dev/null +++ b/src/about/.web/utils/client_side_routing.js @@ -0,0 +1,36 @@ +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), + }) + .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/src/about/.web/utils/helpers/dataeditor.js b/src/about/.web/utils/helpers/dataeditor.js new file mode 100644 index 0000000..9ff3682 --- /dev/null +++ b/src/about/.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/src/about/.web/utils/helpers/range.js b/src/about/.web/utils/helpers/range.js new file mode 100644 index 0000000..7d1aeda --- /dev/null +++ b/src/about/.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/src/about/.web/utils/state.js b/src/about/.web/utils/state.js new file mode 100644 index 0000000..51e8c6b --- /dev/null +++ b/src/about/.web/utils/state.js @@ -0,0 +1,728 @@ +// 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, useReducer, useRef, useState } from "react"; +import Router, { useRouter } from "next/router"; +import { + initialEvents, + initialState, + onLoadInternalEvent, + state_name, +} from "utils/context.js"; + +// 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; +}; + +/** + * 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 }; +}; + +/** + * 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 Router.push(event.payload.path); + return false; + } + + if (event.name == "_console") { + console.log(event.payload.message); + return false; + } + + if (event.name == "_remove_cookie") { + cookies.remove(event.payload.key, { ...event.payload.options }); + queueEvents(initialEvents(), socket); + return false; + } + + if (event.name == "_clear_local_storage") { + localStorage.clear(); + queueEvents(initialEvents(), socket); + return false; + } + + if (event.name == "_remove_local_storage") { + localStorage.removeItem(event.payload.key); + queueEvents(initialEvents(), socket); + return false; + } + + if (event.name == "_set_clipboard") { + const content = event.payload.content; + navigator.clipboard.writeText(content); + return false; + } + + if (event.name == "_download") { + const a = document.createElement("a"); + a.hidden = true; + a.href = event.payload.url; + a.download = event.payload.filename; + a.click(); + a.remove(); + return false; + } + + if (event.name == "_alert") { + alert(event.payload.message); + 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_script") { + try { + const eval_result = eval(event.payload.javascript_code); + 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); + } + 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") { + // 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, otherwise we throw the event into the void + if (!socket) { + 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 "); + } + } + } + + // Once the socket is open, hydrate the page. + socket.current.on("connect", () => { + setConnectErrors([]); + }); + + socket.current.on("connect_error", (error) => { + setConnectErrors((connectErrors) => [connectErrors.slice(-9), error]); + }); + // On each received message, queue the updates and events. + socket.current.on("event", (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 name The name of the event. + * @param payload The payload of the event. + * @param handler The client handler to process event. + * @returns The event object. + */ +export const Event = (name, payload = {}, handler = null) => { + return { name, payload, handler }; +}; + +/** + * 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.cookies || client_storage.local_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]); + } + } + } +}; + +/** + * 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, _e, event_actions) => { + if (event_actions?.preventDefault && _e?.preventDefault) { + _e.preventDefault(); + } + if (event_actions?.stopPropagation && _e?.stopPropagation) { + _e.stopPropagation(); + } + 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]); + + // 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}.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_complete = () => addEvents(onLoadInternalEvent()); + router.events.on("routeChangeComplete", change_complete); + return () => { + 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) => { + return Array.isArray(val) ? val.length > 0 : !!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-CheckboxButton") || + ref.current.className?.includes("rt-SwitchButton") + ) { + 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/src/about/Dockerfile.dev b/src/about/Dockerfile.dev new file mode 100644 index 0000000..f5c6b24 --- /dev/null +++ b/src/about/Dockerfile.dev @@ -0,0 +1,54 @@ +# Stage 1: init +FROM python:3.11 as init + +# Copy local context to `/app` inside container (see .dockerignore) +WORKDIR /app +COPY . . + +# Create virtualenv which will be copied into the final container +ENV VIRTUAL_ENV=/app/.venv +ENV PATH="$VIRTUAL_ENV/bin:$PATH" +RUN python3.11 -m venv $VIRTUAL_ENV + +# Install app requirements and reflex inside virtualenv +RUN pip install -r requirements.txt + +# Deploy templates and prepare app +RUN reflex init + +# Export static copy of frontend to /app/.web/_static +RUN echo "Exporting reflex app to shrink the docker image size, not actually a prod build." +RUN reflex export --frontend-only --no-zip + +# Copy static files out of /app to save space in backend image +RUN mv .web/_static /tmp/_static +RUN rm -rf .web && mkdir .web +RUN mv /tmp/_static .web/_static + +# Stage 2: copy artifacts into slim image +FROM python:3.11-slim +WORKDIR /app +RUN adduser --disabled-password --home /app reflex + +# Install Node.js and unzip +RUN apt-get update && apt-get install -y nodejs unzip curl && curl -fsSL https://bun.sh/install | bash + +# Copy only the necessary files from the "init" stage +COPY --chown=reflex --from=init /app/.venv /app/.venv +COPY --chown=reflex --from=init /app/requirements.txt /app/requirements.txt +COPY --chown=reflex --from=init /app /app + +# Change the ownership and permissions of /app/.local +USER root +RUN mkdir -p /app +RUN chown -R reflex /app +USER reflex + +# Activate the virtual environment and install application requirements +ENV PATH="/app/.venv/bin:$PATH" +RUN python3.11 -m venv /app/.venv +RUN /app/.venv/bin/pip install -r /app/requirements.txt + +# The following lines are for the specific command for the application. +CMD reflex init && reflex run --env dev + diff --git a/src/about/about/__init__.py b/src/about/about/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/about/about/about.py b/src/about/about/about.py new file mode 100644 index 0000000..8ff44ab --- /dev/null +++ b/src/about/about/about.py @@ -0,0 +1,35 @@ +"""Welcome to Reflex! This file outlines the steps to create a basic app.""" + +from rxconfig import config + +import reflex as rx + +docs_url = "https://reflex.dev/docs/getting-started/introduction/" +filename = f"{config.app_name}/{config.app_name}.py" + + +class State(rx.State): + """The app state.""" + + +def index() -> rx.Component: + return rx.center( + rx.theme_panel(), + rx.vstack( + rx.heading("Welcome to Reflex!", size="9"), + rx.text("Get started by editing ", rx.code(filename)), + rx.button( + "Check out our docs!", + on_click=lambda: rx.redirect(docs_url), + size="4", + ), + align="center", + spacing="7", + font_size="2em", + ), + height="100vh", + ) + + +app = rx.App() +app.add_page(index) diff --git a/src/about/assets/favicon.ico b/src/about/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/src/about/requirements.txt b/src/about/requirements.txt new file mode 100644 index 0000000..65c472d --- /dev/null +++ b/src/about/requirements.txt @@ -0,0 +1 @@ +reflex==0.4.3 diff --git a/src/about/rxconfig.py b/src/about/rxconfig.py new file mode 100644 index 0000000..250c229 --- /dev/null +++ b/src/about/rxconfig.py @@ -0,0 +1,5 @@ +import reflex as rx + +config = rx.Config( + app_name="about", +) \ No newline at end of file diff --git a/src/landing/rxconfig.py b/src/landing/rxconfig.py index be33673..2a54299 100644 --- a/src/landing/rxconfig.py +++ b/src/landing/rxconfig.py @@ -2,5 +2,5 @@ import reflex as rx config = rx.Config( app_name="landing", - api_url="http://localhost:8000", + api_url="http://localhost:8000", )