From d3c260a0fac4005c545a9dea812836185f7068bc Mon Sep 17 00:00:00 2001 From: Timothy Pidashev Date: Sat, 9 Mar 2024 03:37:15 -0800 Subject: [PATCH] Begin work on landing --- src/landing/.gitignore | 4 - src/landing/.web/.gitignore | 39 + .../reflex/chakra_color_mode_provider.js | 21 + .../radix_themes_color_mode_provider.js | 22 + src/landing/.web/jsconfig.json | 8 + src/landing/.web/next.config.js | 1 + src/landing/.web/package.json | 25 + src/landing/.web/postcss.config.js | 6 + src/landing/.web/reflex.json | 1 + src/landing/.web/styles/tailwind.css | 3 + src/landing/.web/utils/client_side_routing.js | 36 + src/landing/.web/utils/helpers/dataeditor.js | 69 ++ src/landing/.web/utils/helpers/range.js | 43 ++ src/landing/.web/utils/state.js | 668 ++++++++++++++++++ src/landing/landing/components/__init__.py | 2 + src/landing/landing/components/footer.py | 20 + src/landing/landing/components/navbar.py | 16 + src/landing/landing/landing.py | 43 +- src/landing/landing/pages/__init__.py | 10 + src/landing/landing/pages/index.py | 19 + src/landing/landing/pages/page404.py | 15 + src/landing/landing/route.py | 29 + src/landing/landing/state/__init__.py | 2 + src/landing/landing/state/state.py | 5 + src/landing/landing/state/theme.py | 10 + src/landing/landing/templates/__init__.py | 1 + src/landing/landing/templates/webpage.py | 56 ++ 27 files changed, 1139 insertions(+), 35 deletions(-) delete mode 100644 src/landing/.gitignore create mode 100644 src/landing/.web/.gitignore create mode 100644 src/landing/.web/components/reflex/chakra_color_mode_provider.js create mode 100644 src/landing/.web/components/reflex/radix_themes_color_mode_provider.js create mode 100644 src/landing/.web/jsconfig.json create mode 100644 src/landing/.web/next.config.js create mode 100644 src/landing/.web/package.json create mode 100644 src/landing/.web/postcss.config.js create mode 100644 src/landing/.web/reflex.json create mode 100644 src/landing/.web/styles/tailwind.css create mode 100644 src/landing/.web/utils/client_side_routing.js create mode 100644 src/landing/.web/utils/helpers/dataeditor.js create mode 100644 src/landing/.web/utils/helpers/range.js create mode 100644 src/landing/.web/utils/state.js create mode 100644 src/landing/landing/components/__init__.py create mode 100644 src/landing/landing/components/footer.py create mode 100644 src/landing/landing/components/navbar.py create mode 100644 src/landing/landing/pages/__init__.py create mode 100644 src/landing/landing/pages/index.py create mode 100644 src/landing/landing/pages/page404.py create mode 100644 src/landing/landing/route.py create mode 100644 src/landing/landing/state/__init__.py create mode 100644 src/landing/landing/state/state.py create mode 100644 src/landing/landing/state/theme.py create mode 100644 src/landing/landing/templates/__init__.py create mode 100644 src/landing/landing/templates/webpage.py diff --git a/src/landing/.gitignore b/src/landing/.gitignore deleted file mode 100644 index eab0d4b..0000000 --- a/src/landing/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -*.db -*.py[cod] -.web -__pycache__/ \ No newline at end of file diff --git a/src/landing/.web/.gitignore b/src/landing/.web/.gitignore new file mode 100644 index 0000000..534bc86 --- /dev/null +++ b/src/landing/.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/landing/.web/components/reflex/chakra_color_mode_provider.js b/src/landing/.web/components/reflex/chakra_color_mode_provider.js new file mode 100644 index 0000000..f897522 --- /dev/null +++ b/src/landing/.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/landing/.web/components/reflex/radix_themes_color_mode_provider.js b/src/landing/.web/components/reflex/radix_themes_color_mode_provider.js new file mode 100644 index 0000000..e2dd563 --- /dev/null +++ b/src/landing/.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/landing/.web/jsconfig.json b/src/landing/.web/jsconfig.json new file mode 100644 index 0000000..3c8a325 --- /dev/null +++ b/src/landing/.web/jsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["public/*"] + } + } +} \ No newline at end of file diff --git a/src/landing/.web/next.config.js b/src/landing/.web/next.config.js new file mode 100644 index 0000000..42dad1d --- /dev/null +++ b/src/landing/.web/next.config.js @@ -0,0 +1 @@ +module.exports = {basePath: "", compress: true, reactStrictMode: true, trailingSlash: true}; diff --git a/src/landing/.web/package.json b/src/landing/.web/package.json new file mode 100644 index 0000000..aab86df --- /dev/null +++ b/src/landing/.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/landing/.web/postcss.config.js b/src/landing/.web/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/src/landing/.web/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/src/landing/.web/reflex.json b/src/landing/.web/reflex.json new file mode 100644 index 0000000..5989bd1 --- /dev/null +++ b/src/landing/.web/reflex.json @@ -0,0 +1 @@ +{"version": "0.4.2", "project_hash": 334487535435764683889748963250527836100} \ No newline at end of file diff --git a/src/landing/.web/styles/tailwind.css b/src/landing/.web/styles/tailwind.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/src/landing/.web/styles/tailwind.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/src/landing/.web/utils/client_side_routing.js b/src/landing/.web/utils/client_side_routing.js new file mode 100644 index 0000000..75fb581 --- /dev/null +++ b/src/landing/.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/landing/.web/utils/helpers/dataeditor.js b/src/landing/.web/utils/helpers/dataeditor.js new file mode 100644 index 0000000..9ff3682 --- /dev/null +++ b/src/landing/.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/landing/.web/utils/helpers/range.js b/src/landing/.web/utils/helpers/range.js new file mode 100644 index 0000000..7d1aeda --- /dev/null +++ b/src/landing/.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/landing/.web/utils/state.js b/src/landing/.web/utils/state.js new file mode 100644 index 0000000..088f256 --- /dev/null +++ b/src/landing/.web/utils/state.js @@ -0,0 +1,668 @@ +// 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; + 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 setConnectError The function to update connection error value. + * @param client_storage The client storage object from context.js + */ +export const connect = async ( + socket, + dispatch, + transports, + setConnectError, + 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, + }); + + // Once the socket is open, hydrate the page. + socket.current.on("connect", () => { + setConnectError(null) + }); + + socket.current.on('connect_error', (error) => { + setConnectError(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) + } + }); +}; + +/** + * 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, connectError] - + * addEvents is used to queue an event, and + * connectError is a 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 [connectError, setConnectError] = useState(null) + + // 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'], setConnectError, 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`, {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, connectError] +} + +/*** + * 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(':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/landing/landing/components/__init__.py b/src/landing/landing/components/__init__.py new file mode 100644 index 0000000..a830f66 --- /dev/null +++ b/src/landing/landing/components/__init__.py @@ -0,0 +1,2 @@ +from .navbar import navbar +from .footer import footer diff --git a/src/landing/landing/components/footer.py b/src/landing/landing/components/footer.py new file mode 100644 index 0000000..f67375b --- /dev/null +++ b/src/landing/landing/components/footer.py @@ -0,0 +1,20 @@ +import reflex as rx + +def footer(): + return rx.box( + footer_content(), + background="#FFF", + border_top=f"8px solid {rx.color('mauve', 4)};" + ) + + + +def footer_content(): + return rx.center( + rx.vstack( + rx.heading("Footer", size="9"), + align="center", + spacing="7" + ), + height="15vh" + ) diff --git a/src/landing/landing/components/navbar.py b/src/landing/landing/components/navbar.py new file mode 100644 index 0000000..fbc5537 --- /dev/null +++ b/src/landing/landing/components/navbar.py @@ -0,0 +1,16 @@ +import reflex as rx + + +def navbar(): + return rx.flex( + background = "#FFF", + border_bottom=f"8px solid {rx.color('mauve', 4)};", + height="10vh", + position="fixed", + width="100%", + top="0px", + z_index="5", + align_items= "center", + spacing="6", + padding= "7px 20px 7px 20px;", + ) diff --git a/src/landing/landing/landing.py b/src/landing/landing/landing.py index 4a17519..f35327d 100644 --- a/src/landing/landing/landing.py +++ b/src/landing/landing/landing.py @@ -1,35 +1,16 @@ -"""Welcome to Reflex! This file outlines the steps to create a basic app.""" - -from rxconfig import config - import reflex as rx +from rxconfig import config +from landing.state import * +from landing.pages import * -docs_url = "https://reflex.dev/docs/getting-started/introduction" -filename = f"{config.app_name}/{config.app_name}.py" +# Create app instance and add index page. +app = rx.App( +) - -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", +for route in routes: + app.add_page( + route.component, + route.path, + route.title, + #image="/previews/index_preview.png", ) - - -app = rx.App() -app.add_page(index) diff --git a/src/landing/landing/pages/__init__.py b/src/landing/landing/pages/__init__.py new file mode 100644 index 0000000..d0e5966 --- /dev/null +++ b/src/landing/landing/pages/__init__.py @@ -0,0 +1,10 @@ +from landing.route import Route + +from .index import index +from .page404 import page404 + +routes = [ + *[r for r in locals().values() if isinstance(r, Route)], + #*blog_routes, + #*doc_routes, +] diff --git a/src/landing/landing/pages/index.py b/src/landing/landing/pages/index.py new file mode 100644 index 0000000..76fafdd --- /dev/null +++ b/src/landing/landing/pages/index.py @@ -0,0 +1,19 @@ +import reflex as rx +from landing.components import navbar +from landing.templates import webpage + +@webpage(path="/", title="Timothy Pidashev") +def index() -> rx.Component: + return rx.box( + index_content() + ) + +def index_content(): + return rx.center( + rx.vstack( + rx.heading("Index", size="9"), + align="center", + spacing="7", + ), + height="100vh" + ) diff --git a/src/landing/landing/pages/page404.py b/src/landing/landing/pages/page404.py new file mode 100644 index 0000000..9e78b0c --- /dev/null +++ b/src/landing/landing/pages/page404.py @@ -0,0 +1,15 @@ +import reflex as rx +from landing.templates import webpage + +# TODO: Add a go back here link + +@webpage(path="/404", title="Page Not Found") +def page404(): + return rx.center( + rx.vstack( + rx.heading("Whoops, this page doesn't exist...", size="9"), + rx.spacer(), + ), + height="100vh", + width="100%", + ) diff --git a/src/landing/landing/route.py b/src/landing/landing/route.py new file mode 100644 index 0000000..89c2c6f --- /dev/null +++ b/src/landing/landing/route.py @@ -0,0 +1,29 @@ +"""Manage routing for the application.""" + +import inspect +import reflex as rx +from reflex.base import Base +from typing import Callable + +class Route(Base): + """A Page Route.""" + + # The path of the route. + path: str + + # The page title. + title: str | None = None + + # The component to render for the route. + component: Callable[[], rx.Component] + +def get_path(component_function: Callable): + """Get the path for a page based on the file location. + + Args: + component_function: The component function for the page. + """ + module = inspect.getmodule(component_function) + + # Create a path based on the module name. + return module.__name__.replace(".", "/").replace("_", "-").split("landing/pages")[1] diff --git a/src/landing/landing/state/__init__.py b/src/landing/landing/state/__init__.py new file mode 100644 index 0000000..0a2dce5 --- /dev/null +++ b/src/landing/landing/state/__init__.py @@ -0,0 +1,2 @@ +from .state import State +from .theme import ThemeState diff --git a/src/landing/landing/state/state.py b/src/landing/landing/state/state.py new file mode 100644 index 0000000..bb3a217 --- /dev/null +++ b/src/landing/landing/state/state.py @@ -0,0 +1,5 @@ +import reflex as rx + +class State(rx.State): + """The app state.""" + pass diff --git a/src/landing/landing/state/theme.py b/src/landing/landing/state/theme.py new file mode 100644 index 0000000..1f3631f --- /dev/null +++ b/src/landing/landing/state/theme.py @@ -0,0 +1,10 @@ +import reflex as rx + +from .state import State + +class ThemeState(State): + """State for the global theme""" + theme: str = "day" + + def toggle_theme(self): + self.theme == "day" if self.theme != "day" else self.theme == "night" diff --git a/src/landing/landing/templates/__init__.py b/src/landing/landing/templates/__init__.py new file mode 100644 index 0000000..cac9af0 --- /dev/null +++ b/src/landing/landing/templates/__init__.py @@ -0,0 +1 @@ +from .webpage import webpage diff --git a/src/landing/landing/templates/webpage.py b/src/landing/landing/templates/webpage.py new file mode 100644 index 0000000..ce6fb1d --- /dev/null +++ b/src/landing/landing/templates/webpage.py @@ -0,0 +1,56 @@ +from typing import Callable +import reflex as rx +from landing.route import Route + +def webpage(path: str, title: str = "Timothy Pidashev", props=None) -> Callable: + """This template wraps the webpage with the navbar and footer. + + Args: + path: The path of the page. + title: The title of the page. + props: Props to apply to the template. + + Returns: + A wrapper function that returns the full webpage. + """ + props = props or {} + + def webpage(contents: Callable[[], Route]) -> Route: + """Wrapper to create a templated route. + + Args: + contents: The function to create the page route. + + Returns: + The templated route. + """ + + def wrapper(*children, **props) -> rx.Component: + """The template component. + + Args: + children: The children components. + props: The props to apply to the component. + + Returns: + The component with the template applied. + """ + # Import here to avoid circular imports. + from landing.components.navbar import navbar + from landing.components.footer import footer + + # Wrap the component in the template. + return rx.box( + navbar(), + contents(*children, **props), + footer(), + **props, + ) + + return Route( + path=path, + title=title, + component=wrapper, + ) + + return webpage