diff --git a/examples/chatroom/.gitignore b/examples/chatroom/.gitignore new file mode 100644 index 0000000..d929d82 --- /dev/null +++ b/examples/chatroom/.gitignore @@ -0,0 +1,7 @@ +*.db +*.py[cod] +.web +__pycache__/ +assets/external/ +pynecone.db +reflex.db diff --git a/examples/chatroom/README.md b/examples/chatroom/README.md new file mode 100644 index 0000000..bfe06b5 --- /dev/null +++ b/examples/chatroom/README.md @@ -0,0 +1,35 @@ +# chatroom + +A multi-client chat room. + +NOTE: this example does NOT work in prod mode with redis! + + + +## `broadcast_event` + +This function iterates through all states in the app, applying the Event payload +against each state instance. The resulting change list is passed off to +`pynecone.app.EventNamespace.emit()` directly. + +Either event handlers or other out-of-band callbacks can use this API to emit +Events from the server as if they originated from the client. This preserves the +simple State and Event Handler conceptual model, while allowing server-to-client +communication and state updates at will. + +## `send_message` + +`broadcast_event` is used in the `send_message` event handler, which broadcasts +the `state.incoming_message` event to all connected clients, with the details of +the message. This in itself doesn't trigger any network traffic, unless the +state update creates a delta. + +## `nick_change` + +When the user sets or changes their nick, the event handler first updates the +local state, as usual. Then it awaits, `broadcast_nicks` which collects the full +nick list from the `State` instance of each connected client by iterating over +`app.state_manager.states` values. + +The same `broadcast_event` mechanism described above is then used to pass the +nick list via the `state.set_nicks` event to all connected clients. diff --git a/examples/chatroom/assets/favicon.ico b/examples/chatroom/assets/favicon.ico new file mode 100644 index 0000000..166ae99 Binary files /dev/null and b/examples/chatroom/assets/favicon.ico differ diff --git a/examples/chatroom/assets/screenshot.png b/examples/chatroom/assets/screenshot.png new file mode 100644 index 0000000..804a27b Binary files /dev/null and b/examples/chatroom/assets/screenshot.png differ diff --git a/examples/chatroom/chatroom/__init__.py b/examples/chatroom/chatroom/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/chatroom/chatroom/chatroom.py b/examples/chatroom/chatroom/chatroom.py new file mode 100644 index 0000000..7d695f1 --- /dev/null +++ b/examples/chatroom/chatroom/chatroom.py @@ -0,0 +1,120 @@ +"""Reflex chatroom -- send server events to other sessions.""" +import time +import typing as t + +from rxconfig import config + +import reflex as rx +import reflex_chakra as rc + + +class Message(rx.Base): + nick: str + sent: float + message: str + + +class State(rx.State): + nick: t.Optional[str] = "" + nicks: t.List[str] = [] + messages: t.List[Message] = [] + in_message: str = "" + + def set_nicks(self, nicks: t.List[str]) -> None: + """Set the list of nicks (from broadcast_nicks).""" + self.nicks = nicks + + def incoming_message(self, message: Message) -> None: + """Append incoming message to current message list.""" + self.messages.append(message) + + async def nick_change(self, nick: str) -> None: + """Handle on_blur from nick text input.""" + self.nick = nick + await broadcast_nicks() + + async def send_message(self) -> None: + """Broadcast chat message to other connected clients.""" + m = Message(nick=self.nick, sent=time.time(), message=self.in_message) + await broadcast_event(f"{self.get_full_name()}.incoming_message", payload=dict(message=m)) + self.in_message = "" + + @rx.var + def other_nicks(self) -> t.List[str]: + """Filter nicks list to exclude nick from this instance.""" + return [n for n in self.nicks if n != self.nick] + + +def index() -> rx.Component: + return rc.vstack( + rc.center(rc.heading("Reflex Chat!", font_size="2em")), + rc.hstack( + rc.vstack( + rc.input( + placeholder="Nick", + default_value=State.nick, + on_blur=State.nick_change, + ), + rc.text("Other Users", font_weight="bold"), + rx.foreach(State.other_nicks, rc.text), + width="20vw", + align_items="left", + ), + rc.vstack( + rx.foreach( + State.messages, + lambda m: rc.text("<", m.nick, "> ", m.message), + ), + rc.form( + rc.hstack( + rc.input( + placeholder="Message", + value=State.in_message, + on_change=State.set_in_message, + flex_grow=1, + ), + rc.button("Send", on_click=State.send_message), + ), + on_submit=lambda d: State.send_message(), + ), + width="60vw", + align_items="left", + ), + ), + ) + + +app = rx.App() +app.add_page(index) + + +async def broadcast_event(name: str, payload: t.Dict[str, t.Any] = {}) -> None: + """Simulate frontend event with given name and payload from all clients.""" + responses = [] + for state in app.state_manager.states.values(): + async for update in state._process( + event=rx.event.Event( + token=state.router.session.client_token, + name=name, + router_data=state.router_data, + payload=payload, + ), + ): + # Emit the event. + responses.append( + app.event_namespace.emit( + str(rx.constants.SocketEvent.EVENT), + update.json(), + to=state.router.session.session_id, + ), + ) + for response in responses: + await response + + +async def broadcast_nicks() -> None: + """Simulate State.set_nicks event with updated nick list from all clients.""" + nicks = [] + for state in app.state_manager.states.values(): + nicks.append(state.get_substate(State.get_full_name().split(".")).nick) + await broadcast_event(f"{State.get_full_name()}.set_nicks", payload=dict(nicks=nicks)) diff --git a/examples/chatroom/requirements.txt b/examples/chatroom/requirements.txt new file mode 100644 index 0000000..1988f5e --- /dev/null +++ b/examples/chatroom/requirements.txt @@ -0,0 +1 @@ +reflex>=0.3.8 diff --git a/examples/chatroom/rxconfig.py b/examples/chatroom/rxconfig.py new file mode 100644 index 0000000..97ed239 --- /dev/null +++ b/examples/chatroom/rxconfig.py @@ -0,0 +1,7 @@ +import reflex as rx + +config = rx.Config( + app_name="chatroom", + db_url="sqlite:///reflex.db", + env=rx.Env.DEV, +) diff --git a/examples/clock/.gitignore b/examples/clock/.gitignore new file mode 100644 index 0000000..f671184 --- /dev/null +++ b/examples/clock/.gitignore @@ -0,0 +1,5 @@ +*.db +*.py[cod] +.web +__pycache__/ +reflex.db \ No newline at end of file diff --git a/examples/clock/assets/favicon.ico b/examples/clock/assets/favicon.ico new file mode 100644 index 0000000..8a93bfa Binary files /dev/null and b/examples/clock/assets/favicon.ico differ diff --git a/examples/clock/clock/__init__.py b/examples/clock/clock/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/clock/clock/clock.py b/examples/clock/clock/clock.py new file mode 100644 index 0000000..017c0de --- /dev/null +++ b/examples/clock/clock/clock.py @@ -0,0 +1,214 @@ +"""A Reflex example of a analog clock.""" + +import asyncio +from datetime import datetime, timezone +from typing import Any + +import reflex as rx +import reflex_chakra as rc +import pytz + + +# The supported time zones. +TIMEZONES = [ + "Asia/Tokyo", + "Australia/Sydney", + "Europe/London", + "Europe/Paris", + "Europe/Moscow", + "US/Pacific", + "US/Eastern", +] +DEFAULT_ZONE = TIMEZONES[-2] + + +def rotate(degrees: int) -> str: + """CSS to rotate a clock hand. + + Args: + degrees: The degrees to rotate the clock hand. + + Returns: + The CSS to rotate the clock hand. + """ + return f"rotate({degrees}deg)" + + +class State(rx.State): + """The app state.""" + + # The time zone to display the clock in. + zone: str = rx.Cookie(DEFAULT_ZONE) + + # Whether the clock is running. + running: bool = False + + # The last updated timestamp + _now: datetime = datetime.fromtimestamp(0) + + @rx.cached_var + def valid_zone(self) -> str: + """Get the current time zone. + + Returns: + The current time zone. + """ + try: + pytz.timezone(self.zone) + except Exception: + return DEFAULT_ZONE + return self.zone + + @rx.cached_var + def time_info(self) -> dict[str, Any]: + """Get the current time info. + + This can also be done as several computed vars, but this is more concise. + + Returns: + A dictionary of the current time info. + """ + now = self._now.astimezone(pytz.timezone(self.valid_zone)) + return { + "hour": now.hour if now.hour <= 12 else now.hour % 12, + "minute": now.minute, + "second": now.second, + "meridiem": "AM" if now.hour < 12 else "PM", + "minute_display": f"{now.minute:02}", + "second_display": f"{now.second:02}", + "hour_rotation": rotate(now.hour * 30 - 90), + "minute_rotation": rotate(now.minute * 0.0167 * 360 - 90), + "second_rotation": rotate(now.second * 0.0167 * 360 - 90), + } + + def on_load(self): + """Switch the clock off when the page refreshes.""" + self.running = False + self.refresh() + + def refresh(self): + """Refresh the clock.""" + self._now = datetime.now(timezone.utc) + + @rx.background + async def tick(self): + """Update the clock every second.""" + while self.running: + async with self: + self.refresh() + + # Sleep for a second. + await asyncio.sleep(1) + + def flip_switch(self, running: bool): + """Start or stop the clock. + + Args: + running: Whether the clock should be running. + """ + # Set the switch state. + self.running = running + + # Start the clock if the switch is on. + if self.running: + return State.tick + + +def clock_hand(rotation: str, color: str, length: str) -> rx.Component: + """Create a clock hand. + + Args: + rotation: The rotation of the clock hand. + color: The color of the clock hand. + length: The length of the clock hand. + + Returns: + A clock hand component. + """ + return rc.divider( + transform=rotation, + width=f"{length}em", + position="absolute", + border_style="solid", + border_width="4px", + border_image=f"linear-gradient(to right, rgb(250,250,250) 50%, {color} 100%) 0 0 100% 0", + z_index=0, + ) + + +def analog_clock() -> rx.Component: + """Create the analog clock.""" + return rc.circle( + # The inner circle. + rc.circle( + width="1em", + height="1em", + border_width="thick", + border_color="#43464B", + z_index=1, + ), + # The clock hands. + clock_hand(State.time_info["hour_rotation"], "black", "16"), + clock_hand(State.time_info["minute_rotation"], "red", "18"), + clock_hand(State.time_info["second_rotation"], "blue", "19"), + border_width="thick", + border_color="#43464B", + width="25em", + height="25em", + bg="rgb(250,250,250)", + box_shadow="dark-lg", + ) + + +def digital_clock() -> rx.Component: + """Create the digital clock.""" + return rc.hstack( + rc.heading(State.time_info["hour"]), + rc.heading(":"), + rc.heading(State.time_info["minute_display"]), + rc.heading(":"), + rc.heading(State.time_info["second_display"]), + rc.heading(State.time_info["meridiem"]), + border_width="medium", + border_color="#43464B", + border_radius="2em", + padding_x="2em", + bg="white", + color="#333", + ) + + +def timezone_select() -> rx.Component: + """Create the timezone select.""" + return rc.select( + TIMEZONES, + placeholder="Select a time zone.", + on_change=State.set_zone, + value=State.valid_zone, + bg="#white", + ) + + +def index(): + """The main view.""" + return rc.center( + rc.vstack( + analog_clock(), + rc.hstack( + digital_clock(), + rc.switch(is_checked=State.running, on_change=State.flip_switch), + ), + timezone_select(), + padding="5em", + border_width="medium", + border_color="#43464B", + border_radius="25px", + bg="#ededed", + text_align="center", + ), + padding="5em", + ) + + +app = rx.App() +app.add_page(index, title="Clock", on_load=State.on_load) diff --git a/examples/clock/requirements.txt b/examples/clock/requirements.txt new file mode 100644 index 0000000..63dd597 --- /dev/null +++ b/examples/clock/requirements.txt @@ -0,0 +1,2 @@ +reflex>=0.3.8 +pytz==2022.7.1 \ No newline at end of file diff --git a/examples/clock/rxconfig.py b/examples/clock/rxconfig.py new file mode 100644 index 0000000..cdcf171 --- /dev/null +++ b/examples/clock/rxconfig.py @@ -0,0 +1,7 @@ +import reflex as rx + +config = rx.Config( + app_name="clock", + db_url="sqlite:///reflex.db", + env=rx.Env.DEV, +) diff --git a/examples/crm/.gitignore b/examples/crm/.gitignore new file mode 100644 index 0000000..f671184 --- /dev/null +++ b/examples/crm/.gitignore @@ -0,0 +1,5 @@ +*.db +*.py[cod] +.web +__pycache__/ +reflex.db \ No newline at end of file diff --git a/examples/crm/README.md b/examples/crm/README.md new file mode 100644 index 0000000..c96bca5 --- /dev/null +++ b/examples/crm/README.md @@ -0,0 +1,39 @@ +# Usage of the example's UI + +## Usage - If you are the first time to run this example. +First initialize the database: + +``` +reflex db init +reflex db migrate +``` + +The following steps show how to use this UI when you run this example. +(1) click Log in, get Started button +(2) Sign up account +- type Email in the email field +- type Password in the password field +- click the text `Or sign up with this email and password.` +Remember to type your email and Password before you click the text of sign up. +When you click sign-up, it also helps you to log in. So you can see the Contacts page after login. + +(3) Click Add button to add one contact. +- Add dialog opening +- type Name +- type Email +- click Add button + +Then you can see one contact is in the contacts list. + +(4) click the 'Log out' link. +Then you can see the first page and the `click Log in get started button` button. + +And you can sign up for a new account or log in old account from this page. + +### The function of the Refresh button on the contacts page. +When you log in or sign up a new account to go to a Contacts page, +Don't forget to click the `Refresh` button, +It will refresh UI for your current login account. +You might see old contacts from another account if you don't click the `Refresh` button. + + diff --git a/examples/crm/assets/favicon.ico b/examples/crm/assets/favicon.ico new file mode 100644 index 0000000..8a93bfa Binary files /dev/null and b/examples/crm/assets/favicon.ico differ diff --git a/examples/crm/crm/__init__.py b/examples/crm/crm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/crm/crm/components/__init__.py b/examples/crm/crm/components/__init__.py new file mode 100644 index 0000000..561d183 --- /dev/null +++ b/examples/crm/crm/components/__init__.py @@ -0,0 +1,2 @@ +from .crm import crm +from .navbar import navbar diff --git a/examples/crm/crm/components/crm.py b/examples/crm/crm/components/crm.py new file mode 100644 index 0000000..b9bf1a3 --- /dev/null +++ b/examples/crm/crm/components/crm.py @@ -0,0 +1,127 @@ +from crm.state import State +from crm.state.models import Contact +import reflex as rx +import reflex_chakra as rc + + +class CRMState(State): + contacts: list[Contact] = [] + query = "" + + def get_contacts(self) -> list[Contact]: + with rx.session() as sess: + if self.query != "": + print("Query...") + self.contacts = ( + sess.query(Contact) + .filter(Contact.user_email == self.user.email) + .filter(Contact.contact_name.contains(self.query)) + .all() + ) + return + print("All...") + self.contacts = ( + sess.query(Contact).filter(Contact.user_email == self.user.email).all() + ) + + def filter(self, query): + self.query = query + print("Returning...") + return self.get_contacts() + + @rx.var + def num_contacts(self): + return len(self.contacts) + + +class AddModalState(CRMState): + show: bool = False + name: str = "" + email: str = "" + + def toggle(self): + self.show = not self.show + + def add_contact(self): + if not self.user: + raise ValueError("No user logged in") + with rx.session() as sess: + sess.expire_on_commit = False + sess.add( + Contact( + user_email=self.user.email, contact_name=self.name, email=self.email + ) + ) + sess.commit() + self.toggle() + return self.get_contacts() + + +def add_modal(): + return rc.modal( + rc.modal_overlay( + rc.modal_content( + rc.modal_header("Add"), + rc.modal_body( + rc.input( + on_change=AddModalState.set_name, + placeholder="Name", + margin_bottom="0.5rem", + ), + rc.input(on_change=AddModalState.set_email, placeholder="Email"), + padding_y=0, + ), + rc.modal_footer( + rc.button("Close", on_click=AddModalState.toggle), + rc.button( + "Add", on_click=AddModalState.add_contact, margin_left="0.5rem" + ), + ), + ) + ), + is_open=AddModalState.show, + ) + + +def contact_row( + contact, +): + return rc.tr( + rc.td(contact.contact_name), + rc.td(contact.email), + rc.td(rc.badge(contact.stage)), + ) + + +def crm(): + return rc.box( + rc.button("Refresh", on_click=CRMState.get_contacts), + rc.hstack( + rc.heading("Contacts"), + rc.button("Add", on_click=AddModalState.toggle), + justify_content="space-between", + align_items="flex-start", + margin_bottom="1rem", + ), + rc.responsive_grid( + rc.box( + rc.stat( + rc.stat_label("Contacts"), rc.stat_number(CRMState.num_contacts) + ), + border="1px solid #eaeaef", + padding="1rem", + border_radius=8, + ), + columns=["5"], + margin_bottom="1rem", + ), + add_modal(), + rc.input(placeholder="Filter by name...", on_change=CRMState.filter), + rc.table_container( + rc.table(rc.tbody(rx.foreach(CRMState.contacts, contact_row))), + margin_top="1rem", + ), + width="100%", + max_width="960px", + padding_x="0.5rem", + ) diff --git a/examples/crm/crm/components/navbar.py b/examples/crm/crm/components/navbar.py new file mode 100644 index 0000000..fa045ff --- /dev/null +++ b/examples/crm/crm/components/navbar.py @@ -0,0 +1,31 @@ +from crm.state import State, LoginState +import reflex as rx +import reflex_chakra as rc + + +def navbar(): + return rc.box( + rc.hstack( + rc.link("Pyneknown", href="/", font_weight="medium"), + rc.hstack( + rx.cond( + State.user, + rc.hstack( + rc.link( + "Log out", + color="blue.600", + on_click=LoginState.log_out, + ), + rc.avatar(name=State.user.email, size="md"), + spacing="1rem", + ), + rc.box(), + ) + ), + justify_content="space-between", + ), + width="100%", + padding="1rem", + margin_bottom="2rem", + border_bottom="1px solid black", + ) diff --git a/examples/crm/crm/crm.py b/examples/crm/crm/crm.py new file mode 100644 index 0000000..3ad8a0e --- /dev/null +++ b/examples/crm/crm/crm.py @@ -0,0 +1,11 @@ +"""Welcome to Reflex! This file outlines the steps to create a basic app.""" +import reflex as rx +import reflex_chakra as rc +from crm.pages import index, login +from crm.state import State + + +# Add pages to the app. +app = rx.App() +app.add_page(index) +app.add_page(login) diff --git a/examples/crm/crm/pages/__init__.py b/examples/crm/crm/pages/__init__.py new file mode 100644 index 0000000..e2aab3e --- /dev/null +++ b/examples/crm/crm/pages/__init__.py @@ -0,0 +1,2 @@ +from .index import index +from .login import login diff --git a/examples/crm/crm/pages/index.py b/examples/crm/crm/pages/index.py new file mode 100644 index 0000000..b62f2d9 --- /dev/null +++ b/examples/crm/crm/pages/index.py @@ -0,0 +1,31 @@ +from crm.components import navbar +from crm.components import crm +from crm.state import State +import reflex as rx +import reflex_chakra as rc + + +def index(): + return rc.vstack( + navbar(), + rx.cond( + State.user, + crm(), + rc.vstack( + rc.heading("Welcome to Pyneknown!"), + rc.text( + "This Reflex example demonstrates how to build a fully-fledged customer relationship management (CRM) interface." + ), + rc.link( + rc.button( + "Log in to get started", color_scheme="blue", underline="none" + ), + href="/login", + ), + max_width="500px", + text_align="center", + spacing="1rem", + ), + ), + spacing="1.5rem", + ) diff --git a/examples/crm/crm/pages/login.py b/examples/crm/crm/pages/login.py new file mode 100644 index 0000000..80b9aef --- /dev/null +++ b/examples/crm/crm/pages/login.py @@ -0,0 +1,36 @@ +from crm.state import LoginState +from crm.components import navbar +import reflex as rx +import reflex_chakra as rc + + +def login(): + return rc.vstack( + navbar(), + rc.box( + rc.heading("Log in", margin_bottom="1rem"), + rc.input( + type_="email", + placeholder="Email", + margin_bottom="1rem", + on_change=LoginState.set_email_field, + ), + rc.input( + type_="password", + placeholder="Password", + margin_bottom="1rem", + on_change=LoginState.set_password_field, + ), + rc.button("Log in", on_click=LoginState.log_in), + rc.box( + rc.link( + "Or sign up with this email and password", + href="#", + on_click=LoginState.sign_up, + ), + margin_top="0.5rem", + ), + max_width="350px", + flex_direction="column", + ), + ) diff --git a/examples/crm/crm/state/__init__.py b/examples/crm/crm/state/__init__.py new file mode 100644 index 0000000..febaafc --- /dev/null +++ b/examples/crm/crm/state/__init__.py @@ -0,0 +1,3 @@ +from .state import State +from .login import LoginState +from . import models diff --git a/examples/crm/crm/state/login.py b/examples/crm/crm/state/login.py new file mode 100644 index 0000000..7d0b247 --- /dev/null +++ b/examples/crm/crm/state/login.py @@ -0,0 +1,41 @@ +import reflex as rx +import reflex_chakra as rc +from sqlmodel import select + +from .models import User +from .state import State + + +class LoginState(State): + """State for the login form.""" + + email_field: str = "" + password_field: str = "" + + def log_in(self): + with rx.session() as sess: + user = sess.exec(select(User).where(User.email == self.email_field)).first() + if user and user.password == self.password_field: + self.user = user + return rx.redirect("/") + else: + return rx.window_alert("Wrong username or password.") + + def sign_up(self): + with rx.session() as sess: + user = sess.exec(select(User).where(User.email == self.email_field)).first() + if user: + return rx.window_alert( + "Looks like you’re already registered! Try logging in instead." + ) + else: + sess.expire_on_commit = False # Make sure the user object is accessible. https://sqlalche.me/e/14/bhk3 + user = User(email=self.email_field, password=self.password_field) + self.user = user + sess.add(user) + sess.commit() + return rx.redirect("/") + + def log_out(self): + self.user = None + return rx.redirect("/") diff --git a/examples/crm/crm/state/models.py b/examples/crm/crm/state/models.py new file mode 100644 index 0000000..cf93693 --- /dev/null +++ b/examples/crm/crm/state/models.py @@ -0,0 +1,14 @@ +import reflex as rx +import reflex_chakra as rc + + +class User(rx.Model, table=True): + email: str + password: str + + +class Contact(rx.Model, table=True): + user_email: str + contact_name: str + email: str + stage: str = "lead" diff --git a/examples/crm/crm/state/state.py b/examples/crm/crm/state/state.py new file mode 100644 index 0000000..88c00a2 --- /dev/null +++ b/examples/crm/crm/state/state.py @@ -0,0 +1,10 @@ +from typing import Optional +import reflex as rx +import reflex_chakra as rc +from .models import User + + +class State(rx.State): + """The app state.""" + + user: Optional[User] = None diff --git a/examples/crm/requirements.txt b/examples/crm/requirements.txt new file mode 100644 index 0000000..02a7662 --- /dev/null +++ b/examples/crm/requirements.txt @@ -0,0 +1 @@ +reflex>=0.4.0 \ No newline at end of file diff --git a/examples/crm/rxconfig.py b/examples/crm/rxconfig.py new file mode 100644 index 0000000..cddac80 --- /dev/null +++ b/examples/crm/rxconfig.py @@ -0,0 +1,7 @@ +import reflex as rx + +config = rx.Config( + app_name="crm", + db_url="sqlite:///reflex.db", + env=rx.Env.DEV, +) diff --git a/examples/dalle/.gitignore b/examples/dalle/.gitignore new file mode 100644 index 0000000..f671184 --- /dev/null +++ b/examples/dalle/.gitignore @@ -0,0 +1,5 @@ +*.db +*.py[cod] +.web +__pycache__/ +reflex.db \ No newline at end of file diff --git a/examples/dalle/assets/favicon.ico b/examples/dalle/assets/favicon.ico new file mode 100644 index 0000000..8a93bfa Binary files /dev/null and b/examples/dalle/assets/favicon.ico differ diff --git a/examples/dalle/dalle/__init__.py b/examples/dalle/dalle/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/dalle/dalle/dalle.py b/examples/dalle/dalle/dalle.py new file mode 100644 index 0000000..31aeae4 --- /dev/null +++ b/examples/dalle/dalle/dalle.py @@ -0,0 +1,72 @@ +"""Welcome to Pynecone! This file outlines the steps to create a basic app.""" +from openai import OpenAI + +import reflex as rx +import reflex_chakra as rc + +client = OpenAI() + + +class State(rx.State): + """The app state.""" + + image_url = "" + image_processing = False + image_made = False + + def get_dalle_result(self, form_data: dict[str, str]): + prompt_text: str = form_data["prompt_text"] + self.image_made = False + self.image_processing = True + yield + try: + response = client.images.generate(prompt=prompt_text, n=1, size="1024x1024") + self.image_url = response.data[0].url + self.image_processing = False + self.image_made = True + yield + except Exception as e: + print(e) + self.image_processing = False + yield rx.window_alert("Error with OpenAI Execution.") + + +def index(): + return rc.center( + rc.vstack( + rc.heading("DALL-E", font_size="1.5em"), + rc.form( + rc.input(id="prompt_text", placeholder="Enter a prompt.."), + rc.button( + "Generate Image", + type_="submit", + width="100%", + ), + on_submit=State.get_dalle_result, + ), + rc.divider(), + rx.cond( + State.image_processing, + rc.circular_progress(is_indeterminate=True), + rx.cond( + State.image_made, + rc.image( + src=State.image_url, + height="25em", + width="25em", + ), + ), + ), + bg="white", + padding="2em", + shadow="lg", + border_radius="lg", + ), + width="100%", + height="100vh", + background="radial-gradient(circle at 22% 11%,rgba(62, 180, 137,.20),hsla(0,0%,100%,0) 19%),radial-gradient(circle at 82% 25%,rgba(33,150,243,.18),hsla(0,0%,100%,0) 35%),radial-gradient(circle at 25% 61%,rgba(250, 128, 114, .28),hsla(0,0%,100%,0) 55%)", + ) + + +app = rx.App() +app.add_page(index, title="Reflex:DALL-E") diff --git a/examples/dalle/requirements.txt b/examples/dalle/requirements.txt new file mode 100644 index 0000000..6088ea7 --- /dev/null +++ b/examples/dalle/requirements.txt @@ -0,0 +1,2 @@ +reflex>=0.3.8 +openai>=1 diff --git a/examples/dalle/rxconfig.py b/examples/dalle/rxconfig.py new file mode 100644 index 0000000..88e3afe --- /dev/null +++ b/examples/dalle/rxconfig.py @@ -0,0 +1,10 @@ +import reflex as rx + +class DalleConfig(rx.Config): + pass + +config = DalleConfig( + app_name="dalle", + db_url="sqlite:///reflex.db", + env=rx.Env.DEV, +) diff --git a/examples/datatable_tutorial/.gitignore b/examples/datatable_tutorial/.gitignore new file mode 100644 index 0000000..eab0d4b --- /dev/null +++ b/examples/datatable_tutorial/.gitignore @@ -0,0 +1,4 @@ +*.db +*.py[cod] +.web +__pycache__/ \ No newline at end of file diff --git a/examples/datatable_tutorial/assets/favicon.ico b/examples/datatable_tutorial/assets/favicon.ico new file mode 100644 index 0000000..166ae99 Binary files /dev/null and b/examples/datatable_tutorial/assets/favicon.ico differ diff --git a/examples/datatable_tutorial/datatable_tutorial/__init__.py b/examples/datatable_tutorial/datatable_tutorial/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/datatable_tutorial/datatable_tutorial/datatable_tutorial.py b/examples/datatable_tutorial/datatable_tutorial/datatable_tutorial.py new file mode 100644 index 0000000..20d013b --- /dev/null +++ b/examples/datatable_tutorial/datatable_tutorial/datatable_tutorial.py @@ -0,0 +1,361 @@ +import reflex as rx +import reflex_chakra as rc +from typing import Any +import asyncio +import httpx + + +class BaseState(rx.State): + pass + + +class DataTableState(BaseState): + """The app state.""" + + clicked_cell: str = "Cell clicked: " + edited_cell: str = "Cell edited: " + right_clicked_group_header: str = "Group header right clicked: " + item_hovered: str = "Item Hovered: " + deleted: str = "Deleted: " + + cols: list[dict] = [ + {"title": "Title", "type": "str"}, + { + "title": "Name", + "type": "str", + "group": "Data", + "width": 300, + }, + { + "title": "Birth", + "type": "str", + "group": "Data", + "width": 150, + }, + { + "title": "Human", + "type": "bool", + "group": "Data", + "width": 80, + }, + { + "title": "House", + "type": "str", + "group": "Data", + }, + { + "title": "Wand", + "type": "str", + "group": "Data", + "width": 250, + }, + { + "title": "Patronus", + "type": "str", + "group": "Data", + }, + { + "title": "Blood status", + "type": "str", + "group": "Data", + "width": 200, + }, + ] + + data: list[str] = [ + [ + "1", + "Harry James Potter", + "31 July 1980", + True, + "Gryffindor", + "11' Holly phoenix feather", + "Stag", + "Half-blood", + ], + [ + "2", + "Ronald Bilius Weasley", + "1 March 1980", + True, + "Gryffindor", + "12' Ash unicorn tail hair", + "Jack Russell terrier", + "Pure-blood", + ], + [ + "3", + "Hermione Jean Granger", + "19 September, 1979", + True, + "Gryffindor", + "10¾' vine wood dragon heartstring", + "Otter", + "Muggle-born", + ], + [ + "4", + "Albus Percival Wulfric Brian Dumbledore", + "Late August 1881", + True, + "Gryffindor", + "15' Elder Thestral tail hair core", + "Phoenix", + "Half-blood", + ], + [ + "5", + "Rubeus Hagrid", + "6 December 1928", + False, + "Gryffindor", + "16' Oak unknown core", + "None", + "Part-Human (Half-giant)", + ], + [ + "6", + "Fred Weasley", + "1 April, 1978", + True, + "Gryffindor", + "Unknown", + "Unknown", + "Pure-blood", + ], + [ + "7", + "George Weasley", + "1 April, 1978", + True, + "Gryffindor", + "Unknown", + "Unknown", + "Pure-blood", + ], + ] + + def get_clicked_data(self, pos) -> str: + self.clicked_cell = f"Cell clicked: {pos}" + + def get_edited_data(self, pos, val) -> str: + col, row = pos + self.data[row][col] = val["data"] + self.edited_cell = f"Cell edited: {pos}, Cell value: {val['data']}" + + def get_group_header_right_click(self, index, val): + self.right_clicked_group_header = f"Group header right clicked at index: {index}, Group header value: {val['group']}" + + def get_item_hovered(self, pos) -> str: + self.item_hovered = ( + f"Item Hovered type: {pos['kind']}, Location: {pos['location']}" + ) + + def get_deleted_item(self, selection): + self.deleted = f"Deleted cell: {selection['current']['cell']}" + + # def append_row(self): + # print("13232") + + def column_resize(self, col, width): + self.cols[col["pos"]]["width"] = width + + +class DataTableLiveState(BaseState): + "The app state." + + running: bool + table_data: list[dict[str, Any]] = [] + rate: int = 0.4 + columns: list[dict[str, str]] = [ + { + "title": "id", + "id": "v1", + "type": "int", + "width": 100, + }, + { + "title": "advice", + "id": "v2", + "type": "str", + "width": 750, + }, + ] + + @rx.background + async def live_stream(self): + while True: + await asyncio.sleep(1 / self.rate) + async with self: + if not self.running: + break + + if len(self.table_data) > 50: + self.table_data.pop(0) + + res = httpx.get("https://api.adviceslip.com/advice") + data = res.json() + self.table_data.append( + {"v1": data["slip"]["id"], "v2": data["slip"]["advice"]} + ) + + def toggle_pause(self): + self.running = not self.running + if self.running: + return DataTableLiveState.live_stream + + +darkTheme = { + "accentColor": "#8c96ff", + "accentLight": "rgba(202, 206, 255, 0.253)", + "textDark": "#ffffff", + "textMedium": "#b8b8b8", + "textLight": "#a0a0a0", + "textBubble": "#ffffff", + "bgIconHeader": "#b8b8b8", + "fgIconHeader": "#000000", + "textHeader": "#a1a1a1", + "textHeaderSelected": "#000000", + "bgCell": "#16161b", + "bgCellMedium": "#202027", + "bgHeader": "#212121", + "bgHeaderHasFocus": "#474747", + "bgHeaderHovered": "#404040", + "bgBubble": "#212121", + "bgBubbleSelected": "#000000", + "bgSearchResult": "#423c24", + "borderColor": "rgba(225,225,225,0.2)", + "drilldownBorder": "rgba(225,225,225,0.4)", + "linkColor": "#4F5DFF", + "headerFontStyle": "bold 14px", + "baseFontStyle": "13px", + "fontFamily": "Inter, Roboto, -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui, helvetica neue, helvetica, Ubuntu, noto, arial, sans-serif", +} + + +tab_style = { + "color": "#494369", + "font_weight": 600, + "_selected": { + "color": "#5646ED", + "bg": "#F5EFFE", + "padding_x": "0.5em", + "padding_y": "0.25em", + "border_radius": "8px", + }, +} + + +def index() -> rx.Component: + return rx.fragment( + rc.color_mode_button(rc.color_mode_icon(), float="right"), + rc.vstack( + rc.heading("Data Table Demo!", font_size="2em"), + rc.vstack( + rc.tabs( + rc.tab_list( + rc.tab("Static Data", style=tab_style), + rc.tab("Live Data", style=tab_style), + ), + rc.tab_panels( + rc.tab_panel( + rc.vstack( + rc.heading( + DataTableState.clicked_cell, size="lg", color="blue" + ), + rc.heading( + DataTableState.edited_cell, size="lg", color="green" + ), + rc.heading( + DataTableState.right_clicked_group_header, + size="lg", + color="orange", + ), + rc.heading( + DataTableState.item_hovered, + size="lg", + color="purple", + ), + rc.heading( + DataTableState.deleted, size="lg", color="red" + ), + rx.data_editor( + columns=DataTableState.cols, + data=DataTableState.data, + # rows=10, + on_paste=True, + draw_focus_ring=False, + # fixed_shadow_x=True, + freeze_columns=2, + group_header_height=100, + header_height=80, + # max_column_auto_width=200, + # this works just need to describe it + # max_column_width=200, + min_column_width=100, + row_height=50, + row_markers="clickable-number", + # also mention smooth_scroll_y + smooth_scroll_x=True, + vertical_border=False, + column_select="multi", + # prevent_diagonal_scrolling=False, + overscroll_x=0, + on_cell_clicked=DataTableState.get_clicked_data, + on_cell_edited=DataTableState.get_edited_data, + on_group_header_context_menu=DataTableState.get_group_header_right_click, + on_item_hovered=DataTableState.get_item_hovered, + on_delete=DataTableState.get_deleted_item, + # on_row_appended=DataTableState.append_row, + on_column_resize=DataTableState.column_resize, + theme=darkTheme, + width="80vw", + height="80vh", + ), + ), + ), + rc.tab_panel( + rc.vstack( + rc.stack( + rx.cond( + ~DataTableLiveState.running, + rc.button( + "Start", + on_click=DataTableLiveState.toggle_pause, + color_scheme="green", + ), + rc.button( + "Pause", + on_click=DataTableLiveState.toggle_pause, + color_scheme="red", + ), + ), + ), + rx.data_editor( + columns=DataTableLiveState.columns, + data=DataTableLiveState.table_data, + draw_focus_ring=True, + row_height=50, + smooth_scroll_x=True, + smooth_scroll_y=True, + column_select="single", + # style + theme=darkTheme, + ), + overflow_x="auto", + width="100%", + ), + ), + ), + spacing="1.5em", + font_size="2em", + padding_top="10vh", + width="90vw", + ), + ), + ), + ) + + +app = rx.App() +app.add_page(index) diff --git a/examples/datatable_tutorial/requirements.txt b/examples/datatable_tutorial/requirements.txt new file mode 100644 index 0000000..11eb742 --- /dev/null +++ b/examples/datatable_tutorial/requirements.txt @@ -0,0 +1 @@ +reflex>=0.3.8 \ No newline at end of file diff --git a/examples/datatable_tutorial/rxconfig.py b/examples/datatable_tutorial/rxconfig.py new file mode 100644 index 0000000..34513dd --- /dev/null +++ b/examples/datatable_tutorial/rxconfig.py @@ -0,0 +1,5 @@ +import reflex as rx + +config = rx.Config( + app_name="datatable_tutorial", +) \ No newline at end of file diff --git a/examples/ecommerce/.gitignore b/examples/ecommerce/.gitignore new file mode 100644 index 0000000..f671184 --- /dev/null +++ b/examples/ecommerce/.gitignore @@ -0,0 +1,5 @@ +*.db +*.py[cod] +.web +__pycache__/ +reflex.db \ No newline at end of file diff --git a/examples/ecommerce/assets/favicon.ico b/examples/ecommerce/assets/favicon.ico new file mode 100644 index 0000000..8a93bfa Binary files /dev/null and b/examples/ecommerce/assets/favicon.ico differ diff --git a/examples/ecommerce/ecommerce/__init__.py b/examples/ecommerce/ecommerce/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/ecommerce/ecommerce/ecommerce.py b/examples/ecommerce/ecommerce/ecommerce.py new file mode 100644 index 0000000..5347f54 --- /dev/null +++ b/examples/ecommerce/ecommerce/ecommerce.py @@ -0,0 +1,200 @@ +"""Welcome to Reflex! This file outlines the steps to create a basic app.""" +from rxconfig import config + +import reflex as rx +import reflex_chakra as rc +import json +from icecream import ic +from datetime import datetime +import pandas as pd + + +SEARCH_LABELS = { + "input_name": "Name", + "input_qty": "Quantity", + "input_price": "Unit Price", +} + +PAGE_WIDTH = "60vw" +FULL = "100%" + +FILTERS_TAG = [ + "contains", + "does not contains", + "is empty", + "is not empty", +] + + +class Product(rx.Base): + name: str + quantity: int + price: float + created_at: str + + def __init__(self, row): + name = row.get("name") or row.get("input_name") + qty = row.get("quantity") or int(row.get("input_qty")) + price = row.get("price") or float(row.get("input_price")) + super().__init__( + name=name, + quantity=qty, + price=price, + created_at=datetime.now().isoformat(), + ) + + def sum_value(self): + return self.quantity * self.unit_price + + +class State(rx.State): + """The app state.""" + + input_name: str = "" + input_qty: int = 0 + input_price: float = 0.0 + + search_input: str = "" + invalid_inputs: dict[str, bool] = { + "input_name": False, + "input_qty": False, + "input_price": False, + } + + products: list[Product] = [] + + export_path: str = "" + + def load_products(self): + with open("products.json") as product_file: + data = json.load(product_file) + self.products = [Product(row) for row in data] + + def dump_products(self): + with open("product.json", mode="w") as product_file: + ic("fake serialization") + + @rx.var + def product_data(self) -> list[list]: + return [ + [p.name, p.quantity, f"${p.price}", p.created_at] + for p in self.products + if self.search_input.lower() in p.name.lower() + ] + + def add_product(self, form_data: dict): + ic("add new product", form_data) + invalid = False + for field in form_data.keys(): + ic( + field, + ) + if not form_data.get(field): + self.invalid_inputs[field] = True + invalid = True + else: + try: + type(getattr(self, field))(form_data.get(field)) + self.invalid_inputs[field] = False + except: + self.invalid_inputs[field] = True + invalid = True + + if not invalid: + self.products.append(Product(form_data)) + for field in form_data.keys(): + yield rx.set_value(field, "") + + def export(self): + df = pd.DataFrame(self.product_data) + self.export_path = rx.get_asset_path("data.csv") + df.to_csv(rx.get_asset_path("data.csv")) + yield rx.download("") + + +def inventory(): + search_bar = rc.hstack( + rc.icon(tag="info_outline"), + rc.heading("Products", size="md"), + rc.spacer(), + rc.icon(tag="search"), + rc.input( + placeholder="Search by name", width="20%", on_change=State.set_search_input + ), + width=FULL, + ) + table = rx.data_table( + columns=["Name", "Quantity", "Price", "Created date"], + data=State.product_data, + pagination=True, + sort=True, + # search=True, + ) + return rc.vstack(search_bar, table) + + +def field_input(var, placeholder): + return rc.hstack( + rc.spacer(), + rc.text(SEARCH_LABELS[var._var_name]), + rc.form_control( + rc.input( + id=var._var_name, + placeholder=placeholder, + is_invalid=State.invalid_inputs[var._var_name], + ), + width="50%", + is_required=True, + ), + width=FULL, + ) + + +def filters(): + ... + return rx.fragment() + + +def add_item(): + return rc.vstack( + rc.hstack( + rc.icon(tag="add"), + rc.heading("Add a New Product", size="md"), + rc.spacer(), + width=FULL, + ), + rc.form( + rc.box( + rc.vstack( + field_input(State.input_name, "Product Name"), + field_input(State.input_qty, "Product Quantity"), + field_input(State.input_price, "Product price (in cents)"), + align="right", + ), + padding="15px", + border="black solid 1px", + ), + rc.hstack(rc.spacer(), rc.button("Add Product", type_="submit")), + on_submit=State.add_product, + width=FULL, + ), + width=FULL, + ) + + +def index() -> rx.Component: + return rc.center( + rc.vstack( + rc.heading("E-Commerce Inventory"), + inventory(), + add_item(), + rc.spacer(), + width=PAGE_WIDTH, + height="70%", + ), + height="100vh", + ) + + +app = rx.App() +app.add_page(index, on_load=State.load_products) diff --git a/examples/ecommerce/products.json b/examples/ecommerce/products.json new file mode 100644 index 0000000..4f70279 --- /dev/null +++ b/examples/ecommerce/products.json @@ -0,0 +1,17 @@ +[ + { + "name": "Desk Lamp", + "quantity": 100, + "price": 15.0 + }, + { + "name": "Screen 25'", + "quantity": 20, + "price": 250.0 + }, + { + "name": "Keyboard & Mouse", + "quantity": 30, + "price": 100.0 + } +] \ No newline at end of file diff --git a/examples/ecommerce/requirements.txt b/examples/ecommerce/requirements.txt new file mode 100644 index 0000000..c48d0db --- /dev/null +++ b/examples/ecommerce/requirements.txt @@ -0,0 +1,3 @@ +reflex>=0.3.8 +icecream==2.1.1 +pandas==2.1.0 \ No newline at end of file diff --git a/examples/ecommerce/rxconfig.py b/examples/ecommerce/rxconfig.py new file mode 100644 index 0000000..f48f2e1 --- /dev/null +++ b/examples/ecommerce/rxconfig.py @@ -0,0 +1,10 @@ +import reflex as rx + +class EcommerceConfig(rx.Config): + pass + +config = EcommerceConfig( + app_name="ecommerce", + db_url="sqlite:///reflex.db", + env=rx.Env.DEV, +) \ No newline at end of file diff --git a/examples/fragments/.gitignore b/examples/fragments/.gitignore new file mode 100644 index 0000000..f671184 --- /dev/null +++ b/examples/fragments/.gitignore @@ -0,0 +1,5 @@ +*.db +*.py[cod] +.web +__pycache__/ +reflex.db \ No newline at end of file diff --git a/examples/fragments/assets/favicon.ico b/examples/fragments/assets/favicon.ico new file mode 100644 index 0000000..8a93bfa Binary files /dev/null and b/examples/fragments/assets/favicon.ico differ diff --git a/examples/fragments/fragments/__init__.py b/examples/fragments/fragments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/fragments/fragments/fragments.py b/examples/fragments/fragments/fragments.py new file mode 100644 index 0000000..c7a08a3 --- /dev/null +++ b/examples/fragments/fragments/fragments.py @@ -0,0 +1,59 @@ +"""Welcome to Reflex! This file outlines the steps to create a basic app.""" +from rxconfig import config + +import reflex as rx +import reflex_chakra as rc + +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.""" + + pass + + +def raw_fragment_intro(): + """Raw fragment: Return a raw list of Reflex components, and use * to use the fragment.""" + return [ + rc.heading("This is a raw fragment", font_size="2em"), + rc.box("Just regular Python! Use these with the * operator."), + ] + + +def react_fragment_intro(): + """React fragment: Wrap the result into a `rx.fragment` to take advantage of React fragments. Use normally.""" + return rx.fragment( + rc.heading("This is a React fragment", font_size="2em"), + rc.box( + "Read the fragment docs at ", + rc.link("https://reactjs.org/docs/fragments.html"), + ), + ) + + +def index(): + return rc.center( + rc.vstack( + *raw_fragment_intro(), + react_fragment_intro(), + rc.link( + "Check out our docs!", + href=docs_url, + border="0.1em solid", + padding="0.5em", + border_radius="0.5em", + _hover={ + "color": "rgb(107,99,246)", + }, + ), + spacing="1.5em", + font_size="2em", + ), + padding_top="10%", + ) + + +app = rx.App() +app.add_page(index) diff --git a/examples/fragments/requirements.txt b/examples/fragments/requirements.txt new file mode 100644 index 0000000..11eb742 --- /dev/null +++ b/examples/fragments/requirements.txt @@ -0,0 +1 @@ +reflex>=0.3.8 \ No newline at end of file diff --git a/examples/fragments/rxconfig.py b/examples/fragments/rxconfig.py new file mode 100644 index 0000000..fef2111 --- /dev/null +++ b/examples/fragments/rxconfig.py @@ -0,0 +1,8 @@ +import reflex as rx + + +config = rx.Config( + app_name="fragments", + db_url="sqlite:///reflex.db", + env=rx.Env.DEV, +) diff --git a/examples/local_auth/.gitignore b/examples/local_auth/.gitignore new file mode 100644 index 0000000..eab0d4b --- /dev/null +++ b/examples/local_auth/.gitignore @@ -0,0 +1,4 @@ +*.db +*.py[cod] +.web +__pycache__/ \ No newline at end of file diff --git a/examples/local_auth/README.md b/examples/local_auth/README.md new file mode 100644 index 0000000..3cb7dc2 --- /dev/null +++ b/examples/local_auth/README.md @@ -0,0 +1,91 @@ +# Local Authentication Example + +See example app code: [`local_auth.py`](./local_auth/local_auth.py) + +## Models + +This example makes use of two models, [`User`](./local_auth/user.py) and +[`AuthSession`](./local_auth/auth_session.py), which store user login +information and authenticated user sessions respectively. + +User passwords are hashed in the database with +[`passlib`](https://pypi.org/project/passlib/) using +[`bcrypt`](https://pypi.org/project/bcrypt/) algorithm. However, during +registration and login, the unhashed password is sent over the websocket, so +**it is critical to use TLS to protect the websocket connection**. + +## States + +The base [`State`](./local_auth/base_state.py) class stores the `auth_token` as +a `LocalStorage` var, allowing logins to persist across browser tabs and +sessions. + +It also exposes `authenticated_user` as a cached computed var, which +looks up the `auth_token` in the `AuthSession` table and returns a matching +`User` if any exists. The `is_authenticated` cached var is a convenience for +determining whether the `auth_token` is associated with a valid user. + +The public event handler, `do_logout`, may be called from the frontend and will +destroy the `AuthSession` associated with the current `auth_token`. + +The private event handler, `_login` is only callable from the backend, and +establishes an `AuthSession` for the given `user_id`. It assumes that the +validity of the user credential has already been established, which is why it is +a private handler. + +### Registration + +The [`RegistrationState`](./local_auth/registration.py) class handles the +submission of the register form, checking for input validity and ultimately +creating a new user in the database. + +After successful registration, the event handler redirects back to the login +page after a brief delay. + +### Login + +The [`LoginState`](./local_auth/login.py) class handles the submission of the +login form, checking the user password, and ultimately redirecting back to the +last page that requested login (or the index page). + +The `LoginState.redir` event handler is a bit special because it behaves +differently depending on the page it is called from. + + * If `redir` is called from any page except `/login` and there is no + authenticated user, it saves the current page route as `redirect_to` and + forces a redirect to `/login`. + * If `redir` is called from `/login` and the there is an authenticated + user, it will redirect to the route saved as `redirect_to` (or `/`) + +## Forms and Flow + +### `@require_login` + +The `login.require_login` decorator is intended to be used on pages that require +authentication to be viewed. It uses `rx.cond` to conditionally render either +the wrapped page, or some loading spinners as placeholders. Because one of the +spinners specifies `LoginState.redir` as the event handler for its `on_mount` +trigger, it will handle redirection to the login page if needed. + +### Login Form + +The login form triggers `LoginState.on_submit` when submitted, and this function +is responsible for looking up the user and validating the password against the +database. Once the user is authenticated, `State._login` is called to create the +`AuthSession` associating the `user_id` with the `auth_token` stored in the +browser's `LocalStorage` area. + +Finally `on_submit` chains back into `LoginState.redir` to handle redirection +back to the page that requested the login (stored as `LoginState.redirect_to`). + +### Protect the State + +Keep in mind that **all pages in a reflex app are publicly accessible**! The +`redir` mechanism is designed to get users to and from the login page, it is NOT +designed to protect private data. + +All private data needs to originate from computed vars or event handlers setting +vars after explicitly checking `State.authenticated_user` on the backend. +Static data passed to components, even on protected pages, can be retrieved +without logging in. It cannot be stressed enough that **private data MUST come +from the state**. diff --git a/examples/local_auth/alembic.ini b/examples/local_auth/alembic.ini new file mode 100644 index 0000000..c10d4ca --- /dev/null +++ b/examples/local_auth/alembic.ini @@ -0,0 +1,116 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/examples/local_auth/alembic/README b/examples/local_auth/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/examples/local_auth/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/examples/local_auth/alembic/env.py b/examples/local_auth/alembic/env.py new file mode 100644 index 0000000..36112a3 --- /dev/null +++ b/examples/local_auth/alembic/env.py @@ -0,0 +1,78 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = None + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/examples/local_auth/alembic/script.py.mako b/examples/local_auth/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/examples/local_auth/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/examples/local_auth/alembic/versions/e50402348eab_.py b/examples/local_auth/alembic/versions/e50402348eab_.py new file mode 100644 index 0000000..4650cda --- /dev/null +++ b/examples/local_auth/alembic/versions/e50402348eab_.py @@ -0,0 +1,50 @@ +"""empty message + +Revision ID: e50402348eab +Revises: +Create Date: 2023-12-13 15:23:58.162130 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + +# revision identifiers, used by Alembic. +revision: str = 'e50402348eab' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('authsession', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('session_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('expiration', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_authsession_session_id'), 'authsession', ['session_id'], unique=True) + op.create_index(op.f('ix_authsession_user_id'), 'authsession', ['user_id'], unique=False) + op.create_table('user', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('password_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('enabled', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_user_username'), 'user', ['username'], unique=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_user_username'), table_name='user') + op.drop_table('user') + op.drop_index(op.f('ix_authsession_user_id'), table_name='authsession') + op.drop_index(op.f('ix_authsession_session_id'), table_name='authsession') + op.drop_table('authsession') + # ### end Alembic commands ### diff --git a/examples/local_auth/assets/favicon.ico b/examples/local_auth/assets/favicon.ico new file mode 100644 index 0000000..609f6ab Binary files /dev/null and b/examples/local_auth/assets/favicon.ico differ diff --git a/examples/local_auth/local_auth/__init__.py b/examples/local_auth/local_auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/local_auth/local_auth/auth_session.py b/examples/local_auth/local_auth/auth_session.py new file mode 100644 index 0000000..551338c --- /dev/null +++ b/examples/local_auth/local_auth/auth_session.py @@ -0,0 +1,17 @@ +import datetime + +from sqlmodel import Column, DateTime, Field, func + +import reflex as rx + +class AuthSession( + rx.Model, + table=True, # type: ignore +): + """Correlate a session_id with an arbitrary user_id.""" + + user_id: int = Field(index=True, nullable=False) + session_id: str = Field(unique=True, index=True, nullable=False) + expiration: datetime.datetime = Field( + sa_column=Column(DateTime(timezone=True), server_default=func.now(), nullable=False), + ) diff --git a/examples/local_auth/local_auth/base_state.py b/examples/local_auth/local_auth/base_state.py new file mode 100644 index 0000000..99b707b --- /dev/null +++ b/examples/local_auth/local_auth/base_state.py @@ -0,0 +1,94 @@ +""" +Top-level State for the App. + +Authentication data is stored in the base State class so that all substates can +access it for verifying access to event handlers and computed vars. +""" +import datetime + +from sqlmodel import select + +import reflex as rx + +from .auth_session import AuthSession +from .user import User + + +AUTH_TOKEN_LOCAL_STORAGE_KEY = "_auth_token" +DEFAULT_AUTH_SESSION_EXPIRATION_DELTA = datetime.timedelta(days=7) + + +class State(rx.State): + # The auth_token is stored in local storage to persist across tab and browser sessions. + auth_token: str = rx.LocalStorage(name=AUTH_TOKEN_LOCAL_STORAGE_KEY) + + @rx.cached_var + def authenticated_user(self) -> User: + """The currently authenticated user, or a dummy user if not authenticated. + + Returns: + A User instance with id=-1 if not authenticated, or the User instance + corresponding to the currently authenticated user. + """ + with rx.session() as session: + result = session.exec( + select(User, AuthSession).where( + AuthSession.session_id == self.auth_token, + AuthSession.expiration + >= datetime.datetime.now(datetime.timezone.utc), + User.id == AuthSession.user_id, + ), + ).first() + if result: + user, session = result + return user + return User(id=-1) # type: ignore + + @rx.cached_var + def is_authenticated(self) -> bool: + """Whether the current user is authenticated. + + Returns: + True if the authenticated user has a positive user ID, False otherwise. + """ + return self.authenticated_user.id >= 0 + + def do_logout(self) -> None: + """Destroy AuthSessions associated with the auth_token.""" + with rx.session() as session: + for auth_session in session.exec( + select(AuthSession).where(AuthSession.session_id == self.auth_token) + ).all(): + session.delete(auth_session) + session.commit() + self.auth_token = self.auth_token + + def _login( + self, + user_id: int, + expiration_delta: datetime.timedelta = DEFAULT_AUTH_SESSION_EXPIRATION_DELTA, + ) -> None: + """Create an AuthSession for the given user_id. + + If the auth_token is already associated with an AuthSession, it will be + logged out first. + + Args: + user_id: The user ID to associate with the AuthSession. + expiration_delta: The amount of time before the AuthSession expires. + """ + if self.is_authenticated: + self.do_logout() + if user_id < 0: + return + self.auth_token = self.auth_token or self.router.session.client_token + with rx.session() as session: + session.add( + AuthSession( # type: ignore + user_id=user_id, + session_id=self.auth_token, + expiration=datetime.datetime.now(datetime.timezone.utc) + + expiration_delta, + ) + ) + session.commit() diff --git a/examples/local_auth/local_auth/local_auth.py b/examples/local_auth/local_auth/local_auth.py new file mode 100644 index 0000000..6b34775 --- /dev/null +++ b/examples/local_auth/local_auth/local_auth.py @@ -0,0 +1,48 @@ +"""Main app module to demo local authentication.""" +import reflex as rx +import reflex_chakra as rc + +from .base_state import State +from .login import require_login +from .registration import registration_page as registration_page + + +def index() -> rx.Component: + """Render the index page. + + Returns: + A reflex component. + """ + return rx.fragment( + rc.color_mode_button(rc.color_mode_icon(), float="right"), + rc.vstack( + rc.heading("Welcome to my homepage!", font_size="2em"), + rc.link("Protected Page", href="/protected"), + spacing="1.5em", + padding_top="10%", + ), + ) + + +@require_login +def protected() -> rx.Component: + """Render a protected page. + + The `require_login` decorator will redirect to the login page if the user is + not authenticated. + + Returns: + A reflex component. + """ + return rc.vstack( + rc.heading( + "Protected Page for ", State.authenticated_user.username, font_size="2em" + ), + rc.link("Home", href="/"), + rc.link("Logout", href="/", on_click=State.do_logout), + ) + + +app = rx.App() +app.add_page(index) +app.add_page(protected) diff --git a/examples/local_auth/local_auth/login.py b/examples/local_auth/local_auth/login.py new file mode 100644 index 0000000..4d864a7 --- /dev/null +++ b/examples/local_auth/local_auth/login.py @@ -0,0 +1,119 @@ +"""Login page and authentication logic.""" +import reflex as rx +import reflex_chakra as rc +from sqlmodel import select + +from .base_state import State +from .user import User + + +LOGIN_ROUTE = "/login" +REGISTER_ROUTE = "/register" + + +class LoginState(State): + """Handle login form submission and redirect to proper routes after authentication.""" + + error_message: str = "" + redirect_to: str = "" + + def on_submit(self, form_data) -> rx.event.EventSpec: + """Handle login form on_submit. + + Args: + form_data: A dict of form fields and values. + """ + self.error_message = "" + username = form_data["username"] + password = form_data["password"] + with rx.session() as session: + user = session.exec( + select(User).where(User.username == username) + ).one_or_none() + if user is not None and not user.enabled: + self.error_message = "This account is disabled." + return rx.set_value("password", "") + if user is None or not user.verify(password): + self.error_message = "There was a problem logging in, please try again." + return rx.set_value("password", "") + if ( + user is not None + and user.id is not None + and user.enabled + and user.verify(password) + ): + # mark the user as logged in + self._login(user.id) + self.error_message = "" + return LoginState.redir() # type: ignore + + def redir(self) -> rx.event.EventSpec | None: + """Redirect to the redirect_to route if logged in, or to the login page if not.""" + if not self.is_hydrated: + # wait until after hydration to ensure auth_token is known + return LoginState.redir() # type: ignore + page = self.router.page.path + if not self.is_authenticated and page != LOGIN_ROUTE: + self.redirect_to = page + return rx.redirect(LOGIN_ROUTE) + elif page == LOGIN_ROUTE: + return rx.redirect(self.redirect_to or "/") + + +@rx.page(route=LOGIN_ROUTE) +def login_page() -> rx.Component: + """Render the login page. + + Returns: + A reflex component. + """ + login_form = rc.form( + rc.input(placeholder="username", id="username"), + rc.password(placeholder="password", id="password"), + rc.button("Login", type_="submit"), + width="80vw", + on_submit=LoginState.on_submit, + ) + + return rx.fragment( + rx.cond( + LoginState.is_hydrated, # type: ignore + rc.vstack( + rx.cond( # conditionally show error messages + LoginState.error_message != "", + rc.text(LoginState.error_message), + ), + login_form, + rc.link("Register", href=REGISTER_ROUTE), + padding_top="10vh", + ), + ) + ) + + +def require_login(page: rx.app.ComponentCallable) -> rx.app.ComponentCallable: + """Decorator to require authentication before rendering a page. + + If the user is not authenticated, then redirect to the login page. + + Args: + page: The page to wrap. + + Returns: + The wrapped page component. + """ + + def protected_page(): + return rx.fragment( + rx.cond( + State.is_hydrated & State.is_authenticated, # type: ignore + page(), + rc.center( + # When this spinner mounts, it will redirect to the login page + rc.spinner(on_mount=LoginState.redir), + ), + ) + ) + + protected_page.__name__ = page.__name__ + return protected_page diff --git a/examples/local_auth/local_auth/registration.py b/examples/local_auth/local_auth/registration.py new file mode 100644 index 0000000..484e93a --- /dev/null +++ b/examples/local_auth/local_auth/registration.py @@ -0,0 +1,105 @@ +"""New user registration form and validation logic.""" +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator + +import reflex as rx +import reflex_chakra as rc +from sqlmodel import select + +from .base_state import State +from .login import LOGIN_ROUTE, REGISTER_ROUTE +from .user import User + + +class RegistrationState(State): + """Handle registration form submission and redirect to login page after registration.""" + + success: bool = False + error_message: str = "" + + async def handle_registration( + self, form_data + ) -> AsyncGenerator[rx.event.EventSpec | list[rx.event.EventSpec] | None, None]: + """Handle registration form on_submit. + + Set error_message appropriately based on validation results. + + Args: + form_data: A dict of form fields and values. + """ + with rx.session() as session: + username = form_data["username"] + if not username: + self.error_message = "Username cannot be empty" + yield rx.set_focus("username") + return + existing_user = session.exec( + select(User).where(User.username == username) + ).one_or_none() + if existing_user is not None: + self.error_message = ( + f"Username {username} is already registered. Try a different name" + ) + yield [rx.set_value("username", ""), rx.set_focus("username")] + return + password = form_data["password"] + if not password: + self.error_message = "Password cannot be empty" + yield rx.set_focus("password") + return + if password != form_data["confirm_password"]: + self.error_message = "Passwords do not match" + yield [ + rx.set_value("confirm_password", ""), + rx.set_focus("confirm_password"), + ] + return + # Create the new user and add it to the database. + new_user = User() # type: ignore + new_user.username = username + new_user.password_hash = User.hash_password(password) + new_user.enabled = True + session.add(new_user) + session.commit() + # Set success and redirect to login page after a brief delay. + self.error_message = "" + self.success = True + yield + await asyncio.sleep(0.5) + yield [rx.redirect(LOGIN_ROUTE), RegistrationState.set_success(False)] + + +@rx.page(route=REGISTER_ROUTE) +def registration_page() -> rx.Component: + """Render the registration page. + + Returns: + A reflex component. + """ + register_form = rc.form( + rc.input(placeholder="username", id="username"), + rc.password(placeholder="password", id="password"), + rc.password(placeholder="confirm", id="confirm_password"), + rc.button("Register", type_="submit"), + width="80vw", + on_submit=RegistrationState.handle_registration, + ) + return rx.fragment( + rx.cond( + RegistrationState.success, + rc.vstack( + rc.text("Registration successful!"), + rc.spinner(), + ), + rc.vstack( + rx.cond( # conditionally show error messages + RegistrationState.error_message != "", + rc.text(RegistrationState.error_message), + ), + register_form, + padding_top="10vh", + ), + ) + ) diff --git a/examples/local_auth/local_auth/user.py b/examples/local_auth/local_auth/user.py new file mode 100644 index 0000000..bc58fe4 --- /dev/null +++ b/examples/local_auth/local_auth/user.py @@ -0,0 +1,43 @@ +from passlib.context import CryptContext +from sqlmodel import Field + +import reflex as rx + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +class User( + rx.Model, + table=True, # type: ignore +): + """A local User model with bcrypt password hashing.""" + + username: str = Field(unique=True, nullable=False, index=True) + password_hash: str = Field(nullable=False) + enabled: bool = False + + @staticmethod + def hash_password(secret: str) -> str: + """Hash the secret using bcrypt. + + Args: + secret: The password to hash. + + Returns: + The hashed password. + """ + return pwd_context.hash(secret) + + def verify(self, secret: str) -> bool: + """Validate the user's password. + + Args: + secret: The password to check. + + Returns: + True if the hashed secret matches this user's password_hash. + """ + return pwd_context.verify( + secret, + self.password_hash, + ) diff --git a/examples/local_auth/requirements.txt b/examples/local_auth/requirements.txt new file mode 100644 index 0000000..34a6cd8 --- /dev/null +++ b/examples/local_auth/requirements.txt @@ -0,0 +1,3 @@ +reflex>=0.3.8 +passlib +bcrypt diff --git a/examples/local_auth/rxconfig.py b/examples/local_auth/rxconfig.py new file mode 100644 index 0000000..12e5cf8 --- /dev/null +++ b/examples/local_auth/rxconfig.py @@ -0,0 +1,8 @@ +import reflex as rx + +class LocalauthConfig(rx.Config): + pass + +config = LocalauthConfig( + app_name="local_auth", +) \ No newline at end of file diff --git a/examples/nba/.gitignore b/examples/nba/.gitignore new file mode 100644 index 0000000..f671184 --- /dev/null +++ b/examples/nba/.gitignore @@ -0,0 +1,5 @@ +*.db +*.py[cod] +.web +__pycache__/ +reflex.db \ No newline at end of file diff --git a/examples/nba/README.md b/examples/nba/README.md new file mode 100644 index 0000000..e69de29 diff --git a/examples/nba/assets/favicon.ico b/examples/nba/assets/favicon.ico new file mode 100644 index 0000000..8a93bfa Binary files /dev/null and b/examples/nba/assets/favicon.ico differ diff --git a/examples/nba/assets/nba.png b/examples/nba/assets/nba.png new file mode 100644 index 0000000..004b4ec Binary files /dev/null and b/examples/nba/assets/nba.png differ diff --git a/examples/nba/nba/__init__.py b/examples/nba/nba/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/nba/nba/helpers.py b/examples/nba/nba/helpers.py new file mode 100644 index 0000000..4d8b521 --- /dev/null +++ b/examples/nba/nba/helpers.py @@ -0,0 +1,40 @@ +import reflex as rx +import reflex_chakra as rc + + +def navbar(): + return rc.box( + rc.hstack( + rc.hstack( + rc.image(src="/nba.png", width="50px"), + rc.heading("NBA Data"), + rc.flex( + rc.badge("2015-2016 Season", color_scheme="blue"), + ), + ), + rc.menu( + rc.menu_button( + "Menu", bg="black", color="white", border_radius="md", px=4, py=2 + ), + rc.menu_list( + rc.link(rc.menu_item("Graph"), href="/"), + rc.menu_divider(), + rc.link( + rc.menu_item( + rc.hstack(rc.text("20Dataset"), rc.icon(tag="download")) + ), + href="https://media.geeksforgeeks.org/wp-content/uploads/nba.csv", + ), + ), + ), + justify="space-between", + border_bottom="0.2em solid #F0F0F0", + padding_x="2em", + padding_y="1em", + bg="rgba(255,255,255, 0.97)", + ), + position="fixed", + width="100%", + top="0px", + z_index="500", + ) diff --git a/examples/nba/nba/nba.py b/examples/nba/nba/nba.py new file mode 100644 index 0000000..6152256 --- /dev/null +++ b/examples/nba/nba/nba.py @@ -0,0 +1,146 @@ +"""Welcome to Reflex! This file outlines the steps to create a basic app.""" +import reflex as rx +import reflex_chakra as rc +import pandas as pd +import plotly.express as px +import plotly.graph_objects as go +from .helpers import navbar + +nba_overview = "https://media.geeksforgeeks.org/wp-content/uploads/nba.csv" +nba_data = pd.read_csv(nba_overview) +college = sorted(nba_data["College"].unique().astype(str)) + + +class State(rx.State): + """The app state.""" + + # Filters to apply to the data. + position: str + college: str + age: tuple[int, int] = (0, 50) + salary: tuple[int, int] = (0, 25000000) + + @rx.var + def df(self) -> pd.DataFrame: + """The data.""" + nba = nba_data[ + (nba_data["Age"] > int(self.age[0])) + & (nba_data["Age"] < int(self.age[1])) + & (nba_data["Salary"] > int(self.salary[0])) + & (nba_data["Salary"] < int(self.salary[1])) + ] + + if self.college and self.college != "All": + nba = nba[nba["College"] == self.college] + + if self.position and self.position != "All": + nba = nba[nba["Position"] == self.position] + + if nba.empty: + return pd.DataFrame() + else: + return nba.fillna("") + + @rx.var + def scat_fig(self) -> go.Figure: + """The scatter figure.""" + nba = self.df + + if nba.empty: + return go.Figure() + else: + return px.scatter( + nba, + x="Age", + y="Salary", + title="NBA Age/Salary plot", + color=nba["Position"], + hover_data=["Name"], + symbol=nba["Position"], + trendline="lowess", + trendline_scope="overall", + ) + + @rx.var + def hist_fig(self) -> go.Figure: + """The histogram figure.""" + nba = self.df + + if nba.empty: + return go.Figure() + else: + return px.histogram( + nba, x="Age", y="Salary", title="Age/Salary Distribution" + ) + + +def selection(): + return rc.vstack( + rc.hstack( + rc.vstack( + rc.select( + ["C", "PF", "SF", "PG", "SG"], + placeholder="Select a position. (All)", + on_change=State.set_position, + ), + rc.select( + college, + placeholder="Select a college. (All)", + on_change=State.set_college, + ), + ), + rc.vstack( + rc.vstack( + rc.hstack( + rc.badge("Min Age: ", State.age[0]), + rc.spacer(), + rc.badge("Max Age: ", State.age[1]), + ), + rc.range_slider(on_change_end=State.set_age, min_=18, max_=50), + align_items="left", + width="100%", + ), + rc.vstack( + rc.hstack( + rc.badge("Min Sal: ", State.salary[0] // 1000000, "M"), + rc.spacer(), + rc.badge("Max Sal: ", State.salary[1] // 1000000, "M"), + ), + rc.range_slider( + on_change_end=State.set_salary, min_=0, max_=25000000 + ), + align_items="left", + width="100%", + ), + ), + spacing="1em", + ), + width="100%", + ) + + +def index(): + """The main view.""" + return rc.center( + rc.vstack( + navbar(), + selection(), + rc.divider(), + rx.plotly(data=State.scat_fig, layout={"width": "1000", "height": "600"}), + rx.plotly(data=State.hist_fig, layout={"width": "1000", "height": "600"}), + rx.data_table( + data=nba_data, + pagination=True, + search=True, + sort=True, + resizable=True, + ), + rc.divider(), + padding_top="6em", + width="100%", + ) + ) + + +app = rx.App() +app.add_page(index, title="NBA App") diff --git a/examples/nba/requirements.txt b/examples/nba/requirements.txt new file mode 100644 index 0000000..9166e19 --- /dev/null +++ b/examples/nba/requirements.txt @@ -0,0 +1,3 @@ +reflex>=0.4.0 +pandas>=2.2.0 +plotly>=5.18.0 diff --git a/examples/nba/rxconfig.py b/examples/nba/rxconfig.py new file mode 100644 index 0000000..3879277 --- /dev/null +++ b/examples/nba/rxconfig.py @@ -0,0 +1,5 @@ +import reflex as rx + +config = rx.Config( + app_name="nba", +) \ No newline at end of file diff --git a/examples/qr-scanner/.gitignore b/examples/qr-scanner/.gitignore new file mode 100644 index 0000000..eab0d4b --- /dev/null +++ b/examples/qr-scanner/.gitignore @@ -0,0 +1,4 @@ +*.db +*.py[cod] +.web +__pycache__/ \ No newline at end of file diff --git a/examples/qr-scanner/README.md b/examples/qr-scanner/README.md new file mode 100644 index 0000000..3f7e969 --- /dev/null +++ b/examples/qr-scanner/README.md @@ -0,0 +1,25 @@ +# qr-scanner example + +This QR code scanner wraps the +[@yudiel/react-qr-scanner](https://github.com/yudielcurbelo/react-qr-scanner) +component which seems to have the least amount of issues compared to other +react-based QR scanning components. + +## Chrome and Safari + +Chrome and Safari browser on desktop and mobile (iPhone) will NOT prompt for +camera permissions if the server is not secure (or localhost)! + +Additionally, if your component does not have `on_error` set to a valid +EventHandler, you'll get a rather obtuse error on the frontend like: + +```console +TypeError: p.current is not a function. (In 'p.current(e)', 'p.current' is undefined) +``` + +This is actually a Camera permission issue, but the component has no way of +reporting it, because `onError` is not defined and there is no graceful +fallback. + +Accessing the scanner via a TLS connection should alleviate the issue. Firefox +does not seem to exhibit this behavior. \ No newline at end of file diff --git a/examples/qr-scanner/assets/favicon.ico b/examples/qr-scanner/assets/favicon.ico new file mode 100644 index 0000000..609f6ab Binary files /dev/null and b/examples/qr-scanner/assets/favicon.ico differ diff --git a/examples/qr-scanner/qr_scanner/__init__.py b/examples/qr-scanner/qr_scanner/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/qr-scanner/qr_scanner/component.py b/examples/qr-scanner/qr_scanner/component.py new file mode 100644 index 0000000..0671a51 --- /dev/null +++ b/examples/qr-scanner/qr_scanner/component.py @@ -0,0 +1,34 @@ +from typing import Dict +import reflex as rx +from reflex.components.component import NoSSRComponent +from reflex.vars import BaseVar, Var + + +class QrScanner(NoSSRComponent): + library = "@yudiel/react-qr-scanner" + tag = "QrScanner" + + # The delay between scans in milliseconds. + scan_delay: rx.Var[int] + + # The id of the element to disaply the video preview + video_id: rx.Var[str] + + # Whether to display the scan count overlay on the video. + hide_count: rx.Var[bool] + + container_style: rx.Var[Dict[str, str]] + video_style: rx.Var[Dict[str, str]] + + def get_event_triggers(self) -> Dict[str, Var]: + """Dict mapping (event -> expected arguments).""" + + return { + **super().get_event_triggers(), + "on_result": lambda e0: [e0], + "on_decode": lambda e0: [e0], + "on_error": lambda e0: [Var.create("_e0?.message", _var_is_local=False)], + } + + +qr_scanner = QrScanner.create diff --git a/examples/qr-scanner/qr_scanner/qr_scanner.py b/examples/qr-scanner/qr_scanner/qr_scanner.py new file mode 100644 index 0000000..cf7393e --- /dev/null +++ b/examples/qr-scanner/qr_scanner/qr_scanner.py @@ -0,0 +1,76 @@ +import json +from typing import Any + +import reflex as rx +import reflex_chakra as rc + +from .component import qr_scanner + + +class State(rx.State): + last_scan: str = "" + last_result: dict[str, Any] = {} + last_error: str = "" + + def on_decode(self, decoded: str): + if decoded: + self.last_scan = decoded + + def on_result(self, result: dict[str, Any]): + if result: + self.last_error = "" + self.last_result = result + + def on_error(self, error: str): + self.last_error = error + + @rx.var + def json_result(self) -> str: + return json.dumps(self.last_result, indent=2) + + @rx.var + def is_link(self) -> bool: + return self.last_scan.startswith("http") + + +def index() -> rx.Component: + return rc.vstack( + rc.heading("@yudiel/react-qr-scanner", font_size="2em"), + rc.box( + rx.cond( + State.last_error, + rc.text("Error: ", State.last_error), + ), + ), + rc.box( + qr_scanner( + on_decode=State.on_decode, + on_result=State.on_result, + on_error=State.on_error, + container_style={ + "width": "32vh", + "height": "24vh", + "paddingTop": "0", + }, + ), + ), + rc.center( + rx.cond( + State.last_scan, + rx.cond( + State.is_link, + rc.link(State.last_scan, href=State.last_scan), + rc.text(State.last_scan), + ), + rc.text("Scan a valid QR code"), + ), + border="1px solid black", + width="80vw", + ), + rc.code(State.json_result, white_space="pre-wrap"), + spacing="1.5em", + ) + + +app = rx.App() +app.add_page(index) diff --git a/examples/qr-scanner/requirements.txt b/examples/qr-scanner/requirements.txt new file mode 100644 index 0000000..11eb742 --- /dev/null +++ b/examples/qr-scanner/requirements.txt @@ -0,0 +1 @@ +reflex>=0.3.8 \ No newline at end of file diff --git a/examples/qr-scanner/rxconfig.py b/examples/qr-scanner/rxconfig.py new file mode 100644 index 0000000..2957d6f --- /dev/null +++ b/examples/qr-scanner/rxconfig.py @@ -0,0 +1,10 @@ +import reflex as rx + + +class QrscannerConfig(rx.Config): + pass + + +config = QrscannerConfig( + app_name="qr_scanner", +) diff --git a/examples/quiz/.gitignore b/examples/quiz/.gitignore new file mode 100644 index 0000000..f671184 --- /dev/null +++ b/examples/quiz/.gitignore @@ -0,0 +1,5 @@ +*.db +*.py[cod] +.web +__pycache__/ +reflex.db \ No newline at end of file diff --git a/examples/quiz/assets/favicon.ico b/examples/quiz/assets/favicon.ico new file mode 100644 index 0000000..8a93bfa Binary files /dev/null and b/examples/quiz/assets/favicon.ico differ diff --git a/examples/quiz/quiz/__init__.py b/examples/quiz/quiz/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/quiz/quiz/quiz.py b/examples/quiz/quiz/quiz.py new file mode 100644 index 0000000..eead729 --- /dev/null +++ b/examples/quiz/quiz/quiz.py @@ -0,0 +1,167 @@ +"""Welcome to Reflex! This file outlines the steps to create a basic app.""" +import reflex as rx +import reflex_chakra as rc +import copy +from .results import results +from typing import Any +from typing import List + +question_style = { + "bg": "white", + "padding": "2em", + "border_radius": "25px", + "w": "100%", + "align_items": "left", +} + + +class State(rx.State): + """The app state.""" + + default_answers = [None, None, [False, False, False, False, False]] + answers: List[Any] + answer_key = ["False", "[10, 20, 30, 40]", [False, False, True, True, True]] + score: int + + def onload(self): + self.answers = copy.deepcopy(self.default_answers) + + def set_answers(self, answer, index, sub_index=None): + if sub_index is None: + self.answers[index] = answer + else: + self.answers[index][sub_index] = answer + + def submit(self): + total, correct = 0, 0 + for i in range(len(self.answers)): + if self.answers[i] == self.answer_key[i]: + correct += 1 + total += 1 + self.score = int(correct / total * 100) + return rx.redirect("/result") + + @rx.var + def percent_score(self): + return f"{self.score}%" + + +def header(): + return rc.vstack( + rc.heading("Python Quiz"), + rc.divider(), + rc.text("Here is an example of a quiz made in Reflex."), + rc.text("Once submitted the results will be shown in the results page."), + style=question_style, + ) + + +def question1(): + """The main view.""" + return rc.vstack( + rc.heading("Question #1"), + rc.text( + "In Python 3, the maximum value for an integer is 26", + rc.text("3", as_="sup"), + " - 1", + ), + rc.divider(), + rc.radio_group( + ["True", "False"], + default_value=State.default_answers[0], + default_checked=True, + on_change=lambda answer: State.set_answers(answer, 0), + ), + style=question_style, + ) + + +def question2(): + return rc.vstack( + rc.heading("Question #2"), + rc.text("What is the output of the following addition (+) operator?"), + rc.code_block( + """a = [10, 20] +b = a +b += [30, 40] +print(a)""", + language="python", + ), + rc.radio_group( + ["[10, 20, 30, 40]", "[10, 20]"], + default_value=State.default_answers[1], + default_check=True, + on_change=lambda answer: State.set_answers(answer, 1), + ), + style=question_style, + ) + + +def question3(): + return rc.vstack( + rc.heading("Question #3"), + rc.text( + "Which of the following are valid ways to specify the string literal ", + rc.code("foo'bar"), + " in Python:", + ), + rc.vstack( + rc.checkbox( + rc.code("foo'bar"), + on_change=lambda answer: State.set_answers(answer, 2, 0), + ), + rc.checkbox( + rc.code("'foo''bar'"), + on_change=lambda answer: State.set_answers(answer, 2, 1), + ), + rc.checkbox( + rc.code("'foo\\\\'bar'"), + on_change=lambda answer: State.set_answers(answer, 2, 2), + ), + rc.checkbox( + rc.code('''"""foo'bar"""'''), + on_change=lambda answer: State.set_answers(answer, 2, 3), + ), + rc.checkbox( + rc.code('''"foo'bar"'''), + on_change=lambda answer: State.set_answers(answer, 2, 4), + ), + align_items="left", + ), + style=question_style, + ) + + +def index(): + """The main view.""" + return rc.center( + rc.vstack( + header(), + question1(), + question2(), + question3(), + rc.button( + "Submit", + bg="black", + color="white", + width="6em", + padding="1em", + on_click=State.submit, + ), + spacing="1em", + ), + padding_y="2em", + height="100vh", + align_items="top", + bg="#ededed", + overflow="auto", + ) + + +def result(): + return results(State) + + +app = rx.App() +app.add_page(index, title="Reflex Quiz", on_load=State.onload) +app.add_page(result, title="Quiz Results") diff --git a/examples/quiz/quiz/results.py b/examples/quiz/quiz/results.py new file mode 100644 index 0000000..52add57 --- /dev/null +++ b/examples/quiz/quiz/results.py @@ -0,0 +1,66 @@ +import reflex as rx +import reflex_chakra as rc + +answer_style = { + "border_radius": "10px", + "border": "1px solid #ededed", + "padding": "0.5em", + "align_items": "left", + "shadow": "0px 0px 5px 0px #ededed", +} + + +def render_answer(State, index): + return rc.tr( + rc.td(index + 1), + rc.td( + rx.cond( + State.answers[index].to_string() == State.answer_key[index].to_string(), + rc.icon(tag="check", color="green"), + rc.icon(tag="close", color="red"), + ) + ), + rc.td(State.answers[index].to_string()), + rc.td(State.answer_key[index].to_string()), + ) + + +def results(State): + """The results view.""" + return rc.center( + rc.vstack( + rc.heading("Results"), + rc.text("Below are the results of the quiz."), + rc.divider(), + rc.center( + rc.circular_progress( + rc.circular_progress_label(State.percent_score), + value=State.score, + size="3em", + ) + ), + rc.table( + rc.thead( + rc.tr( + rc.th("#"), + rc.th("Result"), + rc.th("Your Answer"), + rc.th("Correct Answer"), + ) + ), + rx.foreach(State.answers, lambda answer, i: render_answer(State, i)), + ), + rc.box(rc.link(rc.button("Take Quiz Again"), href="/")), + bg="white", + padding_x="5em", + padding_y="2em", + border_radius="25px", + align_items="left", + overflow="auto", + ), + padding="1em", + height="100vh", + align_items="top", + bg="#ededed", + overflow="auto", + ) diff --git a/examples/quiz/requirements.txt b/examples/quiz/requirements.txt new file mode 100644 index 0000000..1988f5e --- /dev/null +++ b/examples/quiz/requirements.txt @@ -0,0 +1 @@ +reflex>=0.3.8 diff --git a/examples/quiz/rxconfig.py b/examples/quiz/rxconfig.py new file mode 100644 index 0000000..d3ed76c --- /dev/null +++ b/examples/quiz/rxconfig.py @@ -0,0 +1,7 @@ +import reflex as rx + +config = rx.Config( + app_name="quiz", + db_url="sqlite:///reflex.db", + env=rx.Env.DEV, +) diff --git a/examples/random-number-range/.gitignore b/examples/random-number-range/.gitignore new file mode 100644 index 0000000..eab0d4b --- /dev/null +++ b/examples/random-number-range/.gitignore @@ -0,0 +1,4 @@ +*.db +*.py[cod] +.web +__pycache__/ \ No newline at end of file diff --git a/examples/random-number-range/assets/favicon.ico b/examples/random-number-range/assets/favicon.ico new file mode 100644 index 0000000..609f6ab Binary files /dev/null and b/examples/random-number-range/assets/favicon.ico differ diff --git a/examples/random-number-range/random_number_range/__init__.py b/examples/random-number-range/random_number_range/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/random-number-range/random_number_range/random_number_range.py b/examples/random-number-range/random_number_range/random_number_range.py new file mode 100644 index 0000000..00024d3 --- /dev/null +++ b/examples/random-number-range/random_number_range/random_number_range.py @@ -0,0 +1,148 @@ +"""This example demonstrates two techniques for achieving a long running task: + +Chained Events: each step of the event recursively queues itself to run again. +Background Tasks: a background task is started and runs until it is cancelled. + +The background task is the newer approach and is generally preferrable because +it does not block UI interaction while it is running. +""" +import asyncio +import random +from typing import List + +import reflex as rx +import reflex_chakra as rc + + +class BaseState(rx.State): + rrange: List[int] = [-10, 10] + delay: int = 1 + _last_values: List[int] = [] + total: int = 0 + + @rx.var + def last_values(self) -> str: + return ", ".join(str(x) for x in self._last_values[-10:]) + + def balance(self): + max_magnitude = max(abs(x) for x in self.rrange) + self.rrange = [-max_magnitude, max_magnitude] + + +class BackgroundState(BaseState): + running: bool = False + loading: bool = False + _n_tasks: int = 0 + + @rx.background + async def run_task(self): + async with self: + if self._n_tasks > 0: + return + self._n_tasks += 1 + + while True: + async with self: + self.loading = True + + # Simulate long running API call + await asyncio.sleep(self.delay) + + async with self: + last_value = random.randint(*self.rrange) + self.total += last_value + self._last_values = self._last_values[-9:] + [last_value] + self.loading = False + if not self.running: + break + + async with self: + self._n_tasks -= 1 + + def set_running(self, value: bool): + self.running = value + if value: + return BackgroundState.run_task + + def single_step(self): + self.running = False + return BackgroundState.run_task + + +class ChainState(BaseState): + running: bool = False + loading: bool = False + + async def run_task(self): + self.loading = True + yield + + # Simulate long running API call + await asyncio.sleep(self.delay) + + last_value = random.randint(*self.rrange) + self.total += last_value + self._last_values = self._last_values[-9:] + [last_value] + self.loading = False + + if self.running: + yield ChainState.run_task + + def set_running(self, value: bool): + self.running = value + if self.running: + return ChainState.run_task + + def single_step(self): + self.running = False + return ChainState.run_task + + +other_links = { + "Chain Events": lambda State: rc.link("Background Task Version", href="/background", on_click=State.set_running(False)), + "Background Task": lambda State: rc.link("Chain Event Version", href="/chain", on_click=State.set_running(False)), +} + + +def random_numbers_in_range(State, mode: str) -> rx.Component: + return rc.center( + rc.vstack( + rc.heading(f"Random Numbers in Range"), + rc.heading(f"{mode} version", font_size="1.5em"), + other_links[mode](State), + rc.hstack( + rc.text("Min: ", State.rrange[0], padding_right="3em"), + rc.button("Balance", on_click=State.balance), + rc.text("Max: ", State.rrange[1], padding_left="3em"), + ), + rc.range_slider(value=State.rrange, on_change=State.set_rrange, min_=-100, max_=100), + rc.hstack( + rc.text("Last 10 values: ", State.last_values), + rx.cond(State.loading, rc.spinner()), + ), + rc.hstack( + rc.text("Total: ", State.total), + rc.button("Clear", on_click=lambda: State.set_total(0)), + ), + rc.hstack( + rc.vstack( + rc.text("Run", font_size="0.7em"), + rc.switch(is_checked=State.running, on_change=State.set_running), + ), + rc.vstack( + rc.text("Delay (sec)", font_size="0.7em"), + rc.select(*[rc.option(x) for x in range(1, 5)], value=State.delay.to(str), on_change=State.set_delay), + padding_right="3em", + ), + rc.button("Single Step", on_click=State.single_step), + align_items="flex-start", + ), + width="50vw", + ), + ) + + +app = rx.App() +app.add_page(rx.fragment(on_mount=rx.redirect("/chain")), route="/") +app.add_page(random_numbers_in_range(ChainState, "Chain Events"), route="/chain") +app.add_page(random_numbers_in_range(BackgroundState, "Background Task"), route="/background") diff --git a/examples/random-number-range/requirements.txt b/examples/random-number-range/requirements.txt new file mode 100644 index 0000000..1988f5e --- /dev/null +++ b/examples/random-number-range/requirements.txt @@ -0,0 +1 @@ +reflex>=0.3.8 diff --git a/examples/random-number-range/rxconfig.py b/examples/random-number-range/rxconfig.py new file mode 100644 index 0000000..b36d140 --- /dev/null +++ b/examples/random-number-range/rxconfig.py @@ -0,0 +1,5 @@ +import reflex as rx + +config = rx.Config( + app_name="random_number_range", +) \ No newline at end of file diff --git a/examples/sales/.gitignore b/examples/sales/.gitignore new file mode 100644 index 0000000..f671184 --- /dev/null +++ b/examples/sales/.gitignore @@ -0,0 +1,5 @@ +*.db +*.py[cod] +.web +__pycache__/ +reflex.db \ No newline at end of file diff --git a/examples/sales/assets/favicon.ico b/examples/sales/assets/favicon.ico new file mode 100644 index 0000000..609f6ab Binary files /dev/null and b/examples/sales/assets/favicon.ico differ diff --git a/examples/sales/assets/pynecone.ico b/examples/sales/assets/pynecone.ico new file mode 100644 index 0000000..8a93bfa Binary files /dev/null and b/examples/sales/assets/pynecone.ico differ diff --git a/examples/sales/requirements.txt b/examples/sales/requirements.txt new file mode 100644 index 0000000..6088ea7 --- /dev/null +++ b/examples/sales/requirements.txt @@ -0,0 +1,2 @@ +reflex>=0.3.8 +openai>=1 diff --git a/examples/sales/rxconfig.py b/examples/sales/rxconfig.py new file mode 100644 index 0000000..7bfc695 --- /dev/null +++ b/examples/sales/rxconfig.py @@ -0,0 +1,7 @@ +import reflex as rx + +config = rx.Config( + app_name="sales", + db_url="sqlite:///reflex.db", + env=rx.Env.DEV, +) diff --git a/examples/sales/sales/__init__.py b/examples/sales/sales/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/sales/sales/models.py b/examples/sales/sales/models.py new file mode 100644 index 0000000..2b383a5 --- /dev/null +++ b/examples/sales/sales/models.py @@ -0,0 +1,13 @@ +import reflex as rx + + +class Customer(rx.Model, table=True): + """The customer model.""" + + customer_name: str + email: str + age: int + gender: str + location: str + job: str + salary: int diff --git a/examples/sales/sales/sales.py b/examples/sales/sales/sales.py new file mode 100644 index 0000000..9c71752 --- /dev/null +++ b/examples/sales/sales/sales.py @@ -0,0 +1,356 @@ +from openai import OpenAI + +import reflex as rx +import reflex_chakra as rc +from sqlmodel import select + +from .models import Customer + +_client = OpenAI() + +def client(): + return _client + +products = { + "T-shirt": { + "description": "A plain white t-shirt made of 100% cotton.", + "price": 10.99, + }, + "Jeans": { + "description": "A pair of blue denim jeans with a straight leg fit.", + "price": 24.99, + }, + "Hoodie": { + "description": "A black hoodie made of a cotton and polyester blend.", + "price": 34.99, + }, + "Cardigan": { + "description": "A grey cardigan with a V-neck and long sleeves.", + "price": 36.99, + }, + "Joggers": { + "description": "A pair of black joggers made of a cotton and polyester blend.", + "price": 44.99, + }, + "Dress": {"description": "A black dress made of 100% polyester.", "price": 49.99}, + "Jacket": { + "description": "A navy blue jacket made of 100% cotton.", + "price": 55.99, + }, + "Skirt": { + "description": "A brown skirt made of a cotton and polyester blend.", + "price": 29.99, + }, + "Shorts": { + "description": "A pair of black shorts made of a cotton and polyester blend.", + "price": 19.99, + }, + "Sweater": { + "description": "A white sweater with a crew neck and long sleeves.", + "price": 39.99, + }, +} + + +class State(rx.State): + """The app state.""" + + customer_name: str = "" + email: str = "" + age: int = 0 + gender: str = "Other" + location: str = "" + job: str = "" + salary: int = 0 + users: list[Customer] = [] + products: dict[str, str] = {} + email_content_data: str = "" + gen_response = False + + def add_customer(self): + """Add a customer to the database.""" + with rx.session() as session: + if session.exec( + select(Customer).where(Customer.email == self.email) + ).first(): + return rx.window_alert("User already exists") + session.add( + Customer( + customer_name=self.customer_name, + email=self.email, + age=self.age, + gender=self.gender, + location=self.location, + job=self.job, + salary=self.salary, + ) + ) + session.commit() + return rx.window_alert(f"User {self.customer_name} has been added.") + + def customer_page(self): + """The customer page.""" + return rx.redirect("/") + + def onboarding_page(self): + """The onboarding page.""" + return rx.redirect("/onboarding") + + def delete_customer(self, email: str): + """Delete a customer from the database.""" + with rx.session() as session: + customer = session.exec( + select(Customer).where(Customer.email == email) + ).first() + session.delete(customer) + session.commit() + + generate_email_data: dict = {} + + async def call_openai(self): + name: str = self.generate_email_data["name"] + email: str = self.generate_email_data["email"] + age: int = self.generate_email_data["age"] + gender: str = self.generate_email_data["gender"] + location: str = self.generate_email_data["location"] + job: str = self.generate_email_data["job"] + salary: int = self.generate_email_data["salary"] + response = client().completions.create( + model="gpt-3.5-turbo-instruct", + prompt=f"Based on these {products} write a sales email to {name} adn email {email} who is {age} years old and a {gender} gender. {name} lives in {location} and works as a {job} and earns {salary} per year. Make sure the email reccomends one product only and is personalized to {name}. The company is named Reflex its website is https://reflex.dev", + temperature=0.7, + max_tokens=2250, + top_p=1, + frequency_penalty=0, + presence_penalty=0, + ) + self.gen_response = False + # save the data related to email_content + self.email_content_data = response.choices[0].text + # update layout of email_content manually + return rx.set_value("email_content", self.email_content_data) + + def generate_email( + self, + name: str, + email: str, + age: int, + gender: str, + location: str, + job: str, + salary: int, + ): + self.generate_email_data["name"] = name + self.generate_email_data["email"] = email + self.generate_email_data["age"] = age + self.generate_email_data["gender"] = gender + self.generate_email_data["location"] = location + self.generate_email_data["job"] = job + self.generate_email_data["salary"] = salary + self.text_area_disabled = True + self.gen_response = True + return State.call_openai + + @rx.var + def get_users(self) -> list[Customer]: + """Get all users from the database.""" + with rx.session() as session: + self.users = session.exec(select(Customer)).all() + return self.users + + def open_text_area(self): + self.text_area_disabled = False + + def close_text_area(self): + self.text_area_disabled = True + + +def navbar(): + """The navbar for the top of the page.""" + return rc.box( + rc.hstack( + rc.link( + rc.hstack( + rc.image(src="/favicon.ico", width="50px"), + rc.heading("Reflex | Personalized Sales", size="lg"), + ), + href="/", + ), + rc.menu( + rc.menu_button( + "Menu", bg="black", color="white", border_radius="md", px=4, py=2 + ), + rc.menu_list( + rc.link( + rc.menu_item( + rc.hstack(rc.text("Customers"), rc.icon(tag="hamburger")) + ), + href="/", + ), + rc.menu_divider(), + rc.link( + rc.menu_item( + rc.hstack(rc.text("Onboarding"), rc.icon(tag="add")) + ), + href="/onboarding", + ), + ), + ), + justify="space-between", + border_bottom="0.2em solid #F0F0F0", + padding_x="2em", + padding_y="1em", + bg="rgba(255,255,255, 0.97)", + ), + position="fixed", + width="100%", + top="0px", + z_index="500", + ) + + +def show_customer(user: Customer): + """Show a customer in a table row.""" + return rc.tr( + rc.td(user.customer_name), + rc.td(user.email), + rc.td(user.age), + rc.td(user.gender), + rc.td(user.location), + rc.td(user.job), + rc.td(user.salary), + rc.td( + rc.button( + "Delete", + on_click=lambda: State.delete_customer(user.email), + bg="red", + color="white", + ) + ), + rc.td( + rc.button( + "Generate Email", + on_click=State.generate_email( + user.customer_name, + user.email, + user.age, + user.gender, + user.location, + user.job, + user.salary, + ), + bg="blue", + color="white", + ) + ), + ) + + +def add_customer(): + """Add a customer to the database.""" + return rc.center( + rc.vstack( + navbar(), + rc.heading("Customer Onboarding"), + rc.hstack( + rc.vstack( + rc.input(placeholder="Input Name", on_blur=State.set_customer_name), + rc.input(placeholder="Input Email", on_blur=State.set_email), + ), + rc.vstack( + rc.input(placeholder="Input Location", on_blur=State.set_location), + rc.input(placeholder="Input Job", on_blur=State.set_job), + ), + ), + rc.select( + ["male", "female", "other"], + placeholder="Select Gender", + on_change=State.set_gender, + ), + rc.input(on_change=State.set_age, placeholder="Age"), + rc.input(on_change=State.set_salary, placeholder="Salary"), + rc.button_group( + rc.button("Submit Customer", on_click=State.add_customer), + rc.button(rc.icon(tag="hamburger"), on_click=State.customer_page), + is_attached=False, + spacing=3, + ), + box_shadow="lg", + bg="#F7FAFC ", + padding="1em", + border="1px solid #ddd", + border_radius="25px", + ), + padding_top="10em", + ) + + +def index(): + """The main page.""" + return rc.center( + rc.vstack( + navbar(), + rc.vstack( + rc.hstack( + rc.heading("Customers"), + rc.button( + rc.icon(tag="add"), + on_click=State.onboarding_page, + bg="#F7FAFC", + border="1px solid #ddd", + ), + ), + rc.table_container( + rc.table( + rc.thead( + rc.tr( + rc.th("Name"), + rc.th("Email"), + rc.th("Age"), + rc.th("Gender"), + rc.th("Location"), + rc.th("Job"), + rc.th("Salary"), + rc.th("Delete"), + rc.th("Generate Email"), + ) + ), + rc.tbody(rx.foreach(State.get_users, show_customer)), + ), + bg="#F7FAFC ", + border="1px solid #ddd", + border_radius="25px", + ), + align_items="left", + padding_top="7em", + ), + rc.vstack( + rc.heading("Generated Email"), + rx.cond( + State.gen_response, + rc.progress(is_indeterminate=True, color="blue", width="100%"), + rc.progress(value=0, width="100%"), + ), + rc.text_area( + id="email_content", + is_disabled=State.gen_response, + on_blur=State.set_email_content_data, + width="100%", + height="100%", + bg="white", + color="black", + placeholder="Response", + min_height="20em", + ), + align_items="left", + width="100%", + padding_top="2em", + ), + ), + padding="1em", + ) + + +app = rx.App(admin_dash=rx.AdminDash(models=[Customer])) +app.add_page(index) +app.add_page(add_customer, "/onboarding") diff --git a/examples/stable_diffusion/.gitignore b/examples/stable_diffusion/.gitignore new file mode 100644 index 0000000..f671184 --- /dev/null +++ b/examples/stable_diffusion/.gitignore @@ -0,0 +1,5 @@ +*.db +*.py[cod] +.web +__pycache__/ +reflex.db \ No newline at end of file diff --git a/examples/stable_diffusion/README.md b/examples/stable_diffusion/README.md new file mode 100644 index 0000000..7cc2ef3 --- /dev/null +++ b/examples/stable_diffusion/README.md @@ -0,0 +1,69 @@ +# Stable Diffusion App + +A user-friendly, highly customizable Python web app that allows you to run image to image stable diffusion locally on both linux with a built-in GPU and mac machines with or without a built-in GPU. + +