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. + +
+icon +
+ + + +# Getting Started + +### 🧬 1. Clone the Repo + +``` +git clone https://github.com/pynecone-io/pynecone-examples.git +``` + +### 📦 2. Install Pynecone and dependencies +First create a new virtual environment of your choice, i.e. conda or venv. + +To get started with Pynecone, you'll need: + +Python 3.7+ +Node.js 12.22.0+ (No JavaScript knowledge required!) +Install Pynecone using pip: + +``` +pip install pynecone +``` + +Then if you are running on mac run: +``` +pip install -r mac_requirements.txt +``` + +Or if you are running on linux run: +``` +pip install -r linux_requirements.txt +``` + +### 🚀 3. Run the application +Navigate to the stable_diffusion directory, initialize and run the app: + +``` +cd stable_diffusion +pc init +pc run +``` + + +# Contributing + +We welcome contributions to improve and extend the Stable Diffusion UI. +If you'd like to contribute, please do the following: +- Fork the repository and make your changes. +- Once you're ready, submit a pull request for review. + + + +## Testing: +- So far this has been tested on GPUs of 16GB and larger, but it should work on GPUs down to a size of 6GB. (if you are getting a CUDA out of memory error, try reducing the size of the image). +- This app should also work on a mac with only a CPU available. If the inference is slow, try reducing the size of the image by changing the width parameter in image_style inside of styles.py, or try reducing the number of inference steps. + +## Still to come: +- text-to-image functionality + +## License +The following repo is licensed under the MIT License. \ No newline at end of file diff --git a/examples/stable_diffusion/assets/favicon.ico b/examples/stable_diffusion/assets/favicon.ico new file mode 100644 index 0000000..8a93bfa Binary files /dev/null and b/examples/stable_diffusion/assets/favicon.ico differ diff --git a/examples/stable_diffusion/docs/stable_diffusion.gif b/examples/stable_diffusion/docs/stable_diffusion.gif new file mode 100644 index 0000000..113b9e2 Binary files /dev/null and b/examples/stable_diffusion/docs/stable_diffusion.gif differ diff --git a/examples/stable_diffusion/linux_requirements.txt b/examples/stable_diffusion/linux_requirements.txt new file mode 100644 index 0000000..441e312 --- /dev/null +++ b/examples/stable_diffusion/linux_requirements.txt @@ -0,0 +1,7 @@ +reflex==0.2.0 +torch +diffusers +pillow +transformers +accelerate +xformers \ No newline at end of file diff --git a/examples/stable_diffusion/mac_requirements.txt b/examples/stable_diffusion/mac_requirements.txt new file mode 100644 index 0000000..04b65de --- /dev/null +++ b/examples/stable_diffusion/mac_requirements.txt @@ -0,0 +1,5 @@ +reflex==0.3.8 +torch +diffusers +pillow +transformers \ No newline at end of file diff --git a/examples/stable_diffusion/requirements.txt b/examples/stable_diffusion/requirements.txt new file mode 100644 index 0000000..04b65de --- /dev/null +++ b/examples/stable_diffusion/requirements.txt @@ -0,0 +1,5 @@ +reflex==0.3.8 +torch +diffusers +pillow +transformers \ No newline at end of file diff --git a/examples/stable_diffusion/rxconfig.py b/examples/stable_diffusion/rxconfig.py new file mode 100644 index 0000000..7538783 --- /dev/null +++ b/examples/stable_diffusion/rxconfig.py @@ -0,0 +1,10 @@ +import reflex as rx + +class StablediffusionConfig(rx.Config): + pass + +config = StablediffusionConfig( + app_name="stable_diffusion", + db_url="sqlite:///reflex.db", + env=rx.Env.DEV, +) \ No newline at end of file diff --git a/examples/stable_diffusion/stable_diffusion/__init__.py b/examples/stable_diffusion/stable_diffusion/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stable_diffusion/stable_diffusion/img2img_linux.py b/examples/stable_diffusion/stable_diffusion/img2img_linux.py new file mode 100644 index 0000000..fe1cde7 --- /dev/null +++ b/examples/stable_diffusion/stable_diffusion/img2img_linux.py @@ -0,0 +1,66 @@ +from typing import Optional + +import torch +from diffusers import EulerDiscreteScheduler, StableDiffusionImg2ImgPipeline +from PIL import Image + + +def img2img_pipe() -> StableDiffusionImg2ImgPipeline: + model_id = "stabilityai/stable-diffusion-2-1" + scheduler = EulerDiscreteScheduler.from_pretrained(model_id, subfolder="scheduler") + pipe = StableDiffusionImg2ImgPipeline.from_pretrained( + model_id, scheduler=scheduler, torch_dtype=torch.float16 + ) + pipe = pipe.to("cuda") + pipe.enable_xformers_memory_efficient_attention() + pipe.enable_model_cpu_offload() + return pipe + + +def img2img( + img: Image, + strength: float = 0.6, + pipe: Optional[StableDiffusionImg2ImgPipeline] = None, + guidance_scale=8.5, + num_inference_steps=150, + prompt: str = "", + negative_prompt: str = "", + seed: Optional[int] = None, +) -> Image: + """Generate an image conditioned on a source image. + + Args: + img: an image to use for conditioning + pipe: a diffusion pipeline to use for anonymisation; Must match the mode. if None, + a new pipeline will be created. + strength: the strength of the diffusion used if mode is 'img2img' + guidance_scale: the guidance scale used for classifier free guidance + num_inference_steps: the number of inference steps to use (for img2img the actual + number of steps ran per image will be num_inference_steps * strength) + prompt: the prompt to use; this will be prepended to the base prompt + negative_prompt: the negative prompt to use; this will be prepended to the base prompt_neg + seed: the seed to use for the random number generator, if None no seed will be set + + Returns: + new image of dimension (width, height) + """ + assert num_inference_steps >= 2, "num_inference_steps must be >= 2" + + if pipe is None: + pipe = img2img_pipe() + + if not isinstance(pipe, StableDiffusionImg2ImgPipeline): + raise ValueError("pipe must be a StableDiffusionImg2ImgPipeline") + + gen = torch.Generator(device="cuda").manual_seed(seed) if seed is not None else None + + return pipe( + prompt=prompt, + image=img, + strength=strength, + negative_prompt=negative_prompt, + guidance_scale=guidance_scale, + num_inference_steps=num_inference_steps, + generator=gen, + num_images_per_prompt=1, + ).images[0] \ No newline at end of file diff --git a/examples/stable_diffusion/stable_diffusion/img2img_mac.py b/examples/stable_diffusion/stable_diffusion/img2img_mac.py new file mode 100644 index 0000000..af750ca --- /dev/null +++ b/examples/stable_diffusion/stable_diffusion/img2img_mac.py @@ -0,0 +1,80 @@ +from typing import Optional +from diffusers import DiffusionPipeline, StableDiffusionImg2ImgPipeline + +import torch +from PIL import Image + + +def img2img( + img: Image, + strength: float = 0.6, + pipe: Optional[StableDiffusionImg2ImgPipeline] = None, + guidance_scale=8.5, + num_inference_steps=150, + prompt: str = "", + negative_prompt: str = "", + seed: Optional[int] = None, +) -> Image: + """Generate an image conditioned on a source image. + + Args: + img: an image to use for conditioning + pipe: a diffusion pipeline to use for anonymisation; Must match the mode. if None, + a new pipeline will be created. + strength: the strength of the diffusion used if mode is 'img2img' + guidance_scale: the guidance scale used for classifier free guidance + num_inference_steps: the number of inference steps to use (for img2img the actual + number of steps ran per image will be num_inference_steps * strength) + prompt: the prompt to use; this will be prepended to the base prompt + negative_prompt: the negative prompt to use; this will be prepended to the base prompt_neg + seed: the seed to use for the random number generator, if None no seed will be set + + Returns: + new image of dimension (width, height) + """ + assert num_inference_steps >= 2, "num_inference_steps must be >= 2" + + pipe = StableDiffusionImg2ImgPipeline.from_pretrained( + "stabilityai/stable-diffusion-2-1" + ) + pipe = pipe.to("mps") + + gen = torch.Generator(device="mps").manual_seed(seed) if seed is not None else None + + # Recommended if your computer has < 64 GB of RAM + pipe.enable_attention_slicing() + + # First-time "warmup" pass if PyTorch version is 1.13 (see explanation above) + _ = pipe(prompt, image=img, num_inference_steps=2, generator=gen) + + return pipe( + prompt=prompt, + image=img, + strength=strength, + negative_prompt=negative_prompt, + guidance_scale=guidance_scale, + num_inference_steps=num_inference_steps, + generator=gen, + num_images_per_prompt=1 + ).images[0] + + + + +# Future code for txt2img functionality: +# def txt2img(): +# pipe = DiffusionPipeline.from_pretrained("stabilityai/stable-diffusion-2-1") +# pipe = pipe.to("mps") + +# # Recommended if your computer has < 64 GB of RAM +# pipe.enable_attention_slicing() + +# prompt = "a photo of an astronaut riding a horse on mars" + +# # First-time "warmup" pass if PyTorch version is 1.13 (see explanation above) +# _ = pipe(prompt, num_inference_steps=1) + +# # Results match those from the CPU device after the warmup pass. +# image = pipe(prompt).images[0] +# print(image) +# image.show() diff --git a/examples/stable_diffusion/stable_diffusion/img2img_mac_cpu_only.py b/examples/stable_diffusion/stable_diffusion/img2img_mac_cpu_only.py new file mode 100644 index 0000000..b47b21e --- /dev/null +++ b/examples/stable_diffusion/stable_diffusion/img2img_mac_cpu_only.py @@ -0,0 +1,53 @@ +from typing import Optional +from diffusers import StableDiffusionImg2ImgPipeline +from PIL import Image + + +def img2img( + img: Image, + strength: float = 0.6, + pipe: Optional[StableDiffusionImg2ImgPipeline] = None, + guidance_scale=8.5, + num_inference_steps=50, + prompt: str = "", + negative_prompt: str = "", + seed: Optional[int] = None, +) -> Image: + """Generate an image conditioned on a source image. + + Args: + img: an image to use for conditioning + pipe: a diffusion pipeline to use for anonymisation; Must match the mode. if None, + a new pipeline will be created. + strength: the strength of the diffusion used if mode is 'img2img' + guidance_scale: the guidance scale used for classifier free guidance + num_inference_steps: the number of inference steps to use (for img2img the actual + number of steps ran per image will be num_inference_steps * strength) + prompt: the prompt to use; this will be prepended to the base prompt + negative_prompt: the negative prompt to use; this will be prepended to the base prompt_neg + seed: the seed to use for the random number generator, if None no seed will be set + + Returns: + new image of dimension (width, height) + """ + assert num_inference_steps >= 2, "num_inference_steps must be >= 2" + + model_id = "runwayml/stable-diffusion-v1-5" + pipe = StableDiffusionImg2ImgPipeline.from_pretrained(model_id) + pipe.safety_checker = None + pipe.requires_safety_checker = False + # Recommended if your computer has < 64 GB of RAM + pipe.enable_attention_slicing() + + # First-time "warmup" pass if PyTorch version is 1.13 (see explanation above) + _ = pipe(prompt, image=img, num_inference_steps=2, num_images_per_prompt=1) + + return pipe( + prompt=prompt, + image=img, + strength=strength, + negative_prompt=negative_prompt, + guidance_scale=guidance_scale, + num_inference_steps=num_inference_steps, + num_images_per_prompt=1, + ).images[0] diff --git a/examples/stable_diffusion/stable_diffusion/stable_diffusion.py b/examples/stable_diffusion/stable_diffusion/stable_diffusion.py new file mode 100644 index 0000000..5c5fd9d --- /dev/null +++ b/examples/stable_diffusion/stable_diffusion/stable_diffusion.py @@ -0,0 +1,170 @@ +"""Welcome to Reflex! This file outlines the steps to create a basic Stable Diffusion app.""" +import reflex as rx +import reflex_chakra as rc +from typing import List +from PIL import Image + +import torch +import platform + +from stable_diffusion.styles import * + +# Check if the operating system is Linux or macOS +os_name = platform.system() +if os_name == "Linux": + from stable_diffusion.img2img_linux import img2img +elif os_name == "Darwin": + if torch.backends.mps.is_available(): + from stable_diffusion.img2img_mac import img2img + else: + from stable_diffusion.img2img_mac_cpu_only import img2img +else: + raise OSError("Unsupported operating system: " + os_name) + + +class State(rx.State): + """The app state.""" + + prompt = "" + negative_prompt = "" + # Takes the final upload from img variable + most_recent_upload = "" + strength_diffusion = 70 + image_processing = False + image_made = False + # Uploaded images. + img: list[str] + inference_steps = 100 + image = Image.open("assets/favicon.ico") + + def process_image(self): + """Set the image processing flag to true and indicate that the image has not been made yet.""" + self.image_made = False + self.image_processing = True + + async def handle_upload(self, files: List[rx.UploadFile]): + """Handle the upload of file(s). + + Args: + files: The uploaded files. + """ + for file in files: + upload_data = await file.read() + outfile = f".web/public/{file.filename}" + + # Save the file. + with open(outfile, "wb") as file_object: + file_object.write(upload_data) + + # Update the img var. + self.img.append(file.filename) + print(self.img) + self.most_recent_upload = self.img[-1] + + def stable_diffusion(self): + image = self.img[-1] + image = f".web/public/{image}" + image = Image.open(image) + rgb_image = image.convert("RGB") + print(self.prompt) + sd_image = img2img( + img=rgb_image, + prompt=self.prompt, + negative_prompt=self.negative_prompt, + strength=(self.strength_diffusion / 100), + guidance_scale=8.5, + num_inference_steps=self.inference_steps, + seed=100, + ) + + self.image_processing = False + self.image_made = True + self.image = sd_image + + +def index(): + """The main view.""" + return rc.center( + rc.vstack( + rc.heading("Stable Diffusion", font_size="2em"), + rx.upload( + rc.vstack( + rc.button( + rc.text("Select File"), + _hover={"bg": accent_color}, + style=input_style, + ), + rc.text("Drag and drop files here or click to select files"), + ), + border=f"1px dotted blue", + padding="5em", + ), + rc.button( + rc.text("Upload"), + _hover={"bg": accent_color}, + style=input_style, + on_click=lambda: State.handle_upload(rx.upload_files()), + ), + rc.image(src=State.most_recent_upload, style=image_style), + rc.vstack( + rc.input( + placeholder="Enter a prompt..", + on_change=State.set_prompt, + _placeholder={"color": "#fffa"}, + _hover={"border_color": accent_color}, + style=input_style, + ), + rc.input( + placeholder="Enter a negative prompt..", + on_change=State.set_negative_prompt, + _placeholder={"color": "#fffa"}, + _hover={"border_color": accent_color}, + style=input_style, + ), + rc.text("Number of inference steps: " + State.inference_steps), + rc.slider( + on_change_end=State.set_inference_steps, + color_scheme="green", + default_value=100, + min_=3, + max_=200, + ), + rc.text("Strength of diffusion: " + State.strength_diffusion), + rc.slider( + on_change_end=State.set_strength_diffusion, + color_scheme="green", + default_value=70, + min_=0, + max_=100, + ), + rc.button( + rc.text("Generate New Image"), + _hover={"bg": accent_color}, + style=input_style, + on_click=[State.process_image, State.stable_diffusion], + width="100%", + ), + rc.divider(), + ), + rx.cond( + State.image_processing, + rc.circular_progress(is_indeterminate=True), + rx.cond( + State.image_made, + rc.image(src=State.image, style=image_style), + ), + ), + bg=border_color, + padding="2em", + shadow="shadow_light", + border_radius="lg", + ), + width="100%", + min_h="100vh", + bg=bg_dark_color, + color=text_light_color, + ) + + +app = rx.App() +app.add_page(index, title="Reflex: Stable Diffusion") diff --git a/examples/stable_diffusion/stable_diffusion/styles.py b/examples/stable_diffusion/stable_diffusion/styles.py new file mode 100644 index 0000000..d29dbfc --- /dev/null +++ b/examples/stable_diffusion/stable_diffusion/styles.py @@ -0,0 +1,96 @@ +import reflex as rx +import reflex_chakra as rc + +bg_dark_color = "#111" +bg_medium_color = "#222" + +border_color = "#fff3" + +accennt_light = "#6649D8" +accent_color = "#5535D4" +accent_dark = "#4C2DB3" + +icon_color = "#fff8" + +text_light_color = "#fff" +shadow_light = "rgba(17, 12, 46, 0.15) 0px 48px 100px 0px;" +shadow = "rgba(50, 50, 93, 0.25) 0px 50px 100px -20px, rgba(0, 0, 0, 0.3) 0px 30px 60px -30px, rgba(10, 37, 64, 0.35) 0px -2px 6px 0px inset;" + +message_style = dict(display="inline-block", p="4", border_radius="xl", max_w="30em") + +input_style = dict( + bg=bg_medium_color, + border_color=border_color, + border_width="1px", + p="4", +) + +image_style = dict( + width="500px", + height="auto", + border_radius="15px 15px", + border="5px solid #555", +) + +icon_style = dict( + font_size="md", + color=icon_color, + _hover=dict(color=text_light_color), + cursor="pointer", + w="8", +) + +sidebar_style = dict( + border="double 1px transparent;", + border_radius="10px;", + background_image=f"linear-gradient({bg_dark_color}, {bg_dark_color}), radial-gradient(circle at top left, {accent_color},{accent_dark});", + background_origin="border-box;", + background_clip="padding-box, border-box;", + p="2", + _hover=dict( + background_image=f"linear-gradient({bg_dark_color}, {bg_dark_color}), radial-gradient(circle at top left, {accent_color},{accennt_light});", + ), +) + +base_style = { + rc.Avatar: { + "shadow": shadow, + "color": text_light_color, + "bg": border_color, + }, + rc.Button: { + "shadow": shadow, + "color": text_light_color, + "_hover": { + "bg": accent_dark, + }, + }, + rc.Menu: { + "bg": bg_dark_color, + "border": f"red", + }, + rc.MenuList: { + "bg": bg_dark_color, + "border": f"1.5px solid {bg_medium_color}", + }, + rc.MenuDivider: { + "border": f"1px solid {bg_medium_color}", + }, + rc.MenuItem: { + "bg": bg_dark_color, + "color": text_light_color, + }, + rc.DrawerContent: { + "bg": bg_dark_color, + "color": text_light_color, + "opacity": "0.9", + }, + rc.Hstack: { + "align_items": "center", + "justify_content": "space-between", + }, + rc.Vstack: { + "align_items": "stretch", + "justify_content": "space-between", + }, +} \ No newline at end of file diff --git a/examples/tailwind/.gitignore b/examples/tailwind/.gitignore new file mode 100644 index 0000000..f671184 --- /dev/null +++ b/examples/tailwind/.gitignore @@ -0,0 +1,5 @@ +*.db +*.py[cod] +.web +__pycache__/ +reflex.db \ No newline at end of file diff --git a/examples/tailwind/assets/favicon.ico b/examples/tailwind/assets/favicon.ico new file mode 100644 index 0000000..8a93bfa Binary files /dev/null and b/examples/tailwind/assets/favicon.ico differ diff --git a/examples/tailwind/assets/tailwind.png b/examples/tailwind/assets/tailwind.png new file mode 100644 index 0000000..e4cdfc2 Binary files /dev/null and b/examples/tailwind/assets/tailwind.png differ diff --git a/examples/tailwind/requirements.txt b/examples/tailwind/requirements.txt new file mode 100644 index 0000000..1988f5e --- /dev/null +++ b/examples/tailwind/requirements.txt @@ -0,0 +1 @@ +reflex>=0.3.8 diff --git a/examples/tailwind/rxconfig.py b/examples/tailwind/rxconfig.py new file mode 100644 index 0000000..20f2718 --- /dev/null +++ b/examples/tailwind/rxconfig.py @@ -0,0 +1,13 @@ +import reflex as rx + + +class TailwindConfig(rx.Config): + pass + + +config = TailwindConfig( + app_name="tailwind", + db_url="sqlite:///reflex.db", + env=rx.Env.DEV, + tailwind={}, +) diff --git a/examples/tailwind/tailwind/__init__.py b/examples/tailwind/tailwind/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/tailwind/tailwind/tailwind.py b/examples/tailwind/tailwind/tailwind.py new file mode 100644 index 0000000..34d7db4 --- /dev/null +++ b/examples/tailwind/tailwind/tailwind.py @@ -0,0 +1,94 @@ +"""Welcome to Reflex! This file outlines the steps to create a basic app.""" + +import reflex as rx +from reflex import el + + +class State(rx.State): + pass + + +def code_block(code: str, inline: bool = False): + code_el = el.code( + code, + class_name=f"bg-gray-100 rounded text-sm font-mono {'p-1 mx-2 inline-block' if inline else 'block p-4'}", + ) + if inline: + return code_el + return el.pre( + code_el, + class_name="block my-4", + ) + + +def toggle(): + return el.img(src="/tailwind.png", class_name="h-5") + + +PCCONFIG = """import reflex as rx +import reflex_chakra as rc + + +class TailwindConfig(rx.Config): + pass + + +config = TailwindConfig( + app_name="tailwind", + db_url="sqlite:///reflex.db", + env=rx.Env.DEV, + tailwind={}, +) +""" + +EXAMPLE_TAILWIND_DICT = """{ + "theme": { + "extend": { + "colors": { + "primary": "#ff0000", + "secondary": "#00ff00", + "tertiary": "#0000ff" + } + } + }, + "plugins": [ + "@tailwindcss/typography" + ] + }""" + + +def index() -> rx.Component: + return el.div( + toggle(), + el.p( + "This is a Reflex app with Tailwind baked in.", + class_name="text-gray-500 my-4", + ), + el.div( + el.a( + "Open Tailwind docs", + href="https://tailwindcss.com/docs", + target="_blank", + class_name="font-semibold block border rounded-lg px-4 py-1 shadow-sm hover:bg-sky-500 hover:border-sky-500 hover:-translate-y-0.5 transition-all hover:shadow-lg hover:text-white", + ), + class_name="flex", + ), + el.hr(class_name="my-8"), + el.p( + "Just add a ", + code_block("tailwind={}", True), + "key to your config, and Reflex takes care of the rest.", + ), + code_block(PCCONFIG), + el.p( + "You have access to the full Tailwind configuration API through the dictionary you pass in. Plugins will be automatically wrapped in", + code_block("require()", True), + ":", + ), + code_block(PCCONFIG.format(EXAMPLE_TAILWIND_DICT)), + class_name="container mx-auto px-4 py-24 max-w-screen-md", + ) + + +app = rx.App() +app.add_page(index) diff --git a/examples/translator/.gitignore b/examples/translator/.gitignore new file mode 100644 index 0000000..f671184 --- /dev/null +++ b/examples/translator/.gitignore @@ -0,0 +1,5 @@ +*.db +*.py[cod] +.web +__pycache__/ +reflex.db \ No newline at end of file diff --git a/examples/translator/assets/favicon.ico b/examples/translator/assets/favicon.ico new file mode 100644 index 0000000..8a93bfa Binary files /dev/null and b/examples/translator/assets/favicon.ico differ diff --git a/examples/translator/requirements.txt b/examples/translator/requirements.txt new file mode 100644 index 0000000..0f657b8 --- /dev/null +++ b/examples/translator/requirements.txt @@ -0,0 +1,3 @@ +reflex>=0.3.8 +googletrans-py==4.0.0 +requests>=2.28.1 diff --git a/examples/translator/rxconfig.py b/examples/translator/rxconfig.py new file mode 100644 index 0000000..a688a4f --- /dev/null +++ b/examples/translator/rxconfig.py @@ -0,0 +1,7 @@ +import reflex as rx + +config = rx.Config( + app_name="translator", + db_url="sqlite:///reflex.db", + env=rx.Env.DEV, +) diff --git a/examples/translator/translator/__init__.py b/examples/translator/translator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/translator/translator/langs.py b/examples/translator/translator/langs.py new file mode 100644 index 0000000..5d2c0c6 --- /dev/null +++ b/examples/translator/translator/langs.py @@ -0,0 +1,124 @@ +""" +A dictionary mapping language names to language codes. +The language codes are the same ones used in the Google Translate API. + +Example: + >>> langs["Afrikaans"] + "af" + >>> langs["en"] + "English" + >>> langs["Chinese (Simplified)"] + "zh-CN" +""" + +langs = { + "Afrikaans": "af", + "Albanian": "sq", + "Amharic": "am", + "Arabic": "ar", + "Armenian": "hy", + "Azerbaijani": "az", + "Basque": "eu", + "Belarusian": "be", + "Bengali": "bn", + "Bosnian": "bs", + "Bulgarian": "bg", + "Catalan": "ca", + "Cebuano": "ceb", + "Chinese (Simplified)": "zh-CN", + "Chinese (Traditional)": "zh-TW", + "Corsican": "co", + "Croatian": "hr", + "Czech": "cs", + "Danish": "da", + "Dutch": "nl", + "English": "en", + "Esperanto": "eo", + "Estonian": "et", + "Finnish": "fi", + "French": "fr", + "Frisian": "fy", + "Galician": "gl", + "Georgian": "ka", + "German": "de", + "Greek": "el", + "Gujarati": "gu", + "Haitian Creole": "ht", + "Hausa": "ha", + "Hawaiian": "haw", + "Hebrew": "he", + "Hindi": "hi", + "Hmong": "hmn", + "Hungarian": "hu", + "Icelandic": "is", + "Igbo": "ig", + "Indonesian": "id", + "Irish": "ga", + "Italian": "it", + "Japanese": "ja", + "Javanese": "jw", + "Kannada": "kn", + "Kazakh": "kk", + "Khmer": "km", + "Kinyarwanda": "rw", + "Korean": "ko", + "Kurdish": "ku", + "Kyrgyz": "ky", + "Lao": "lo", + "Latin": "la", + "Latvian": "lv", + "Lithuanian": "lt", + "Luxembourgish": "lb", + "Macedonian": "mk", + "Malagasy": "mg", + "Malay": "ms", + "Malayalam": "ml", + "Maltese": "mt", + "Maori": "mi", + "Marathi": "mr", + "Mongolian": "mn", + "Myanmar (Burmese)": "my", + "Nepali": "ne", + "Norwegian": "no", + "Nyanja (Chichewa)": "ny", + "Oriya": "or", + "Pashto": "ps", + "Persian": "fa", + "Polish": "pl", + "Portuguese": "pt", + "Punjabi": "pa", + "Romanian": "ro", + "Russian": "ru", + "Samoan": "sm", + "Scots Gaelic": "gd", + "Serbian": "sr", + "Sesotho": "st", + "Shona": "sn", + "Sindhi": "sd", + "Sinhala": "si", + "Slovak": "sk", + "Slovenian": "sl", + "Somali": "so", + "Spanish": "es", + "Sundanese": "su", + "Swahili": "sw", + "Swedish": "sv", + "Tagalog": "tl", + "Tajik": "tg", + "Tamil": "ta", + "Tatar": "tt", + "Telugu": "te", + "Thai": "th", + "Turkish": "tr", + "Turkmen": "tk", + "Ukrainian": "uk", + "Urdu": "ur", + "Uyghur": "ug", + "Uzbek": "uz", + "Vietnamese": "vi", + "Welsh": "cy", + "Xhosa": "xh", + "Yiddish": "yi", + "Yoruba": "yo", + "Zulu": "zu", +} diff --git a/examples/translator/translator/translator.py b/examples/translator/translator/translator.py new file mode 100644 index 0000000..1fb7f1d --- /dev/null +++ b/examples/translator/translator/translator.py @@ -0,0 +1,166 @@ +"""Welcome to Reflex! This file outlines the steps to create a basic app.""" + +# Import reflex. +from datetime import datetime +from googletrans import Translator + +import reflex as rx +import reflex_chakra as rc +from reflex.base import Base + +from .langs import langs + +trans = Translator() + +class Message(Base): + original_text: str + text: str + created_at: str + to_lang: str + + +class State(rx.State): + """The app state.""" + + text: str = "" + messages: list[Message] = [] + lang: str = "Chinese (Simplified)" + + @rx.var + def output(self) -> str: + if not self.text.strip(): + return "Translations will appear here." + translated = trans.translate(self.text,dest=self.lang) + return translated.text + + def post(self): + self.messages = [ + Message( + original_text=self.text, + text=self.output, + created_at=datetime.now().strftime("%B %d, %Y %I:%M %p"), + to_lang=self.lang, + ) + ] + self.messages + + +# Define views. + + +def header(): + """Basic instructions to get started.""" + return rc.box( + rc.text("Translator 🗺", font_size="2rem"), + rc.text( + "Translate things and post them as messages!", + margin_top="0.5rem", + color="#666", + ), + ) + + +def down_arrow(): + return rc.vstack( + rc.icon( + tag="arrow_down", + color="#666", + ) + ) + + +def text_box(text): + return rc.text( + text, + background_color="#fff", + padding="1rem", + border_radius="8px", + ) + + +def message(message): + return rc.box( + rc.vstack( + text_box(message.original_text), + down_arrow(), + text_box(message.text), + rc.box( + rc.text(message.to_lang), + rc.text(" · ", margin_x="0.3rem"), + rc.text(message.created_at), + display="flex", + font_size="0.8rem", + color="#666", + ), + spacing="0.3rem", + align_items="left", + ), + background_color="#f5f5f5", + padding="1rem", + border_radius="8px", + ) + + +def smallcaps(text, **kwargs): + return rc.text( + text, + font_size="0.7rem", + font_weight="bold", + text_transform="uppercase", + letter_spacing="0.05rem", + **kwargs, + ) + + +def output(): + return rc.box( + rc.box( + smallcaps( + "Output", + color="#aeaeaf", + background_color="white", + padding_x="0.1rem", + ), + position="absolute", + top="-0.5rem", + ), + rc.text(State.output), + padding="1rem", + border="1px solid #eaeaef", + margin_top="1rem", + border_radius="8px", + position="relative", + ) + + +def index(): + """The main view.""" + return rc.container( + header(), + rc.input( + placeholder="Text to translate", + on_blur=State.set_text, + margin_top="1rem", + border_color="#eaeaef", + ), + rc.select( + list(langs.keys()), + value=State.lang, + placeholder="Select a language", + on_change=State.set_lang, + margin_top="1rem", + ), + output(), + rc.button("Post", on_click=State.post, margin_top="1rem"), + rc.vstack( + rx.foreach(State.messages, message), + margin_top="2rem", + spacing="1rem", + align_items="left", + ), + padding="2rem", + max_width="600px", + ) + + +app = rx.App() +app.add_page(index, title="Translator") diff --git a/examples/traversal/.gitignore b/examples/traversal/.gitignore new file mode 100644 index 0000000..f671184 --- /dev/null +++ b/examples/traversal/.gitignore @@ -0,0 +1,5 @@ +*.db +*.py[cod] +.web +__pycache__/ +reflex.db \ No newline at end of file diff --git a/examples/traversal/assets/favicon.ico b/examples/traversal/assets/favicon.ico new file mode 100644 index 0000000..8a93bfa Binary files /dev/null and b/examples/traversal/assets/favicon.ico differ diff --git a/examples/traversal/requirements.txt b/examples/traversal/requirements.txt new file mode 100644 index 0000000..11eb742 --- /dev/null +++ b/examples/traversal/requirements.txt @@ -0,0 +1 @@ +reflex>=0.3.8 \ No newline at end of file diff --git a/examples/traversal/rxconfig.py b/examples/traversal/rxconfig.py new file mode 100644 index 0000000..958b754 --- /dev/null +++ b/examples/traversal/rxconfig.py @@ -0,0 +1,7 @@ +import reflex as rx + +config = rx.Config( + app_name="traversal", + db_url="sqlite:///reflex.db", + env=rx.Env.DEV, +) diff --git a/examples/traversal/traversal/__init__.py b/examples/traversal/traversal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/traversal/traversal/traversal.py b/examples/traversal/traversal/traversal.py new file mode 100644 index 0000000..1b60793 --- /dev/null +++ b/examples/traversal/traversal/traversal.py @@ -0,0 +1,203 @@ +from rxconfig import config + +import reflex as rx +import random +import asyncio +from collections import deque + +GRID_SIZE = 7 + + +def generate_graph(walls, size) -> list[list[int]]: + """Generate a 2D grid of size x size with walls number of walls.""" + color = [0] * (size**2) + + while walls > 0: + wall = random.randint(size, size**2 - 1) + if color[wall] != "blue": + color[wall] = "blue" + walls -= 1 + + color[random.randint(0, size - 1)] = "red" + color[random.randint(size, size**2 - 1)] = "green" + + def to_matrix(l, n): + return [l[i : i + n] for i in range(0, len(l), n)] + + return to_matrix(color, GRID_SIZE) + + +class State(rx.State): + """The app state.""" + + option: str = "" + walls: int = 0 + colored_graph: list[list[int]] = generate_graph(walls, GRID_SIZE) + initial: bool = True + s: list = [] + q: list = [] + + def set_walls(self, value): + if value != "": + if int(value) >= 0: + self.walls = int(value) + + def new_graph(self): + """Reset the state with parameters.""" + self.colored_graph = generate_graph(self.walls, GRID_SIZE) + self.initial = True + self.s = [] + self.q = [] + + def run(self): + """Run the selected algorithm.""" + if self.option == "DFS": + return State.run_dfs + elif self.option == "BFS": + return State.run_bfs + + async def run_dfs(self): + """DFS algorithm on a 1d array.""" + await asyncio.sleep(0.01) + colors = self.colored_graph + + if self.initial: + for i in range(len(colors)): + for j in range(len(colors[i])): + if colors[i][j] == "red": + self.s.append((i, j)) + self.initial = False + break + + directions = [(1, 0), (-1, 0), (0, 1), (0, -1)] + + if self.s: + i, j = self.s.pop() + + if colors[i][j] == "green": + self.colored_graph = colors + return + + if colors[i][j] != "red": + colors[i][j] = "yellow" + + self.colored_graph = colors + + for di, dj in directions: + i2, j2 = i + di, j + dj + if ( + 0 <= i2 < len(colors) + and 0 <= j2 < len(colors[i2]) + and colors[i2][j2] != "yellow" + and colors[i2][j2] != "blue" + ): + self.s.append((i2, j2)) + return State.run_dfs + + return rx.window_alert("No path found") + + async def run_bfs(self): + await asyncio.sleep(0.000000000000000001) + colors = self.colored_graph + q = deque() + + if self.q != []: + for item in self.q: + q.append(item) + + directions = [(1, 0), (-1, 0), (0, 1), (0, -1)] + + if self.initial: + for i in range(len(colors)): + for j in range(len(colors[i])): + if colors[i][j] == "red": + q.append((i, j)) + self.q.append((i, j)) + self.initial = False + break + + if q: + i, j = q.popleft() + self.q.pop(0) + + if colors[i][j] == "green": + self.colored_graph = colors + return + + if colors[i][j] != "red": + colors[i][j] = "yellow" + + self.colored_graph = colors + + for di, dj in directions: + i2, j2 = i + di, j + dj + if ( + 0 <= i2 < len(colors) + and 0 <= j2 < len(colors[i2]) + and colors[i2][j2] != "yellow" + and colors[i2][j2] != "blue" + ): + q.append((i2, j2)) + self.q.append((i2, j2)) + return State.run_bfs + + return rx.window_alert("No path found") + + +def render_box(color): + """Return a colored box.""" + return rx.box(bg=color, width="50px", height="50px", border="1px solid black") + + +def index(): + return rx.center( + rx.vstack( + rx.heading("Graph Traversal", font_size="2.8em"), + rx.hstack( + rx.number_input( + on_change=State.set_walls, + bg="white", + min_=0, + max_=20, + is_invalid=False, + default_value=0, + ), + rx.button( + "Generate Graph", + on_click=State.new_graph, + width="100%", + bg="white", + ), + ), + rx.responsive_grid( + rx.foreach( + State.colored_graph, lambda x: rx.vstack(rx.foreach(x, render_box)) + ), + columns=[GRID_SIZE], + spacing="2", + justify="center", + ), + rx.hstack( + rx.select( + ["DFS", "BFS"], + placeholder="Select an algorithm..", + on_change=State.set_option, + width="100%", + bg="white", + ), + rx.button( + "run", + on_click=State.run, + width="50%", + bg="white", + ), + width="100%", + ), + bg="#cdcdcd", + padding="2em", + ), + ) + + +app = rx.App() +app.add_page(index) diff --git a/examples/twitter/.gitignore b/examples/twitter/.gitignore new file mode 100644 index 0000000..f671184 --- /dev/null +++ b/examples/twitter/.gitignore @@ -0,0 +1,5 @@ +*.db +*.py[cod] +.web +__pycache__/ +reflex.db \ No newline at end of file diff --git a/examples/twitter/README.md b/examples/twitter/README.md new file mode 100644 index 0000000..6c77d50 --- /dev/null +++ b/examples/twitter/README.md @@ -0,0 +1,8 @@ +# Twitter Example + +Initialize the database first: + +``` +reflex db init +reflex db migrate +``` diff --git a/examples/twitter/assets/bg.svg b/examples/twitter/assets/bg.svg new file mode 100644 index 0000000..c6fda80 --- /dev/null +++ b/examples/twitter/assets/bg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/twitter/assets/favicon.ico b/examples/twitter/assets/favicon.ico new file mode 100644 index 0000000..8a93bfa Binary files /dev/null and b/examples/twitter/assets/favicon.ico differ diff --git a/examples/twitter/assets/signup.svg b/examples/twitter/assets/signup.svg new file mode 100644 index 0000000..db23d6d --- /dev/null +++ b/examples/twitter/assets/signup.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/twitter/requirements.txt b/examples/twitter/requirements.txt new file mode 100644 index 0000000..1988f5e --- /dev/null +++ b/examples/twitter/requirements.txt @@ -0,0 +1 @@ +reflex>=0.3.8 diff --git a/examples/twitter/rxconfig.py b/examples/twitter/rxconfig.py new file mode 100644 index 0000000..a973ac2 --- /dev/null +++ b/examples/twitter/rxconfig.py @@ -0,0 +1,7 @@ +import reflex as rx + +config = rx.Config( + app_name="twitter", + db_url="sqlite:///reflex.db", + env=rx.Env.DEV, +) diff --git a/examples/twitter/twitter/__init__.py b/examples/twitter/twitter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/twitter/twitter/components/__init__.py b/examples/twitter/twitter/components/__init__.py new file mode 100644 index 0000000..2b3eeaa --- /dev/null +++ b/examples/twitter/twitter/components/__init__.py @@ -0,0 +1,2 @@ +"""Re-export components.""" +from .container import container diff --git a/examples/twitter/twitter/components/container.py b/examples/twitter/twitter/components/container.py new file mode 100644 index 0000000..7cd7dbe --- /dev/null +++ b/examples/twitter/twitter/components/container.py @@ -0,0 +1,20 @@ +"""A container component.""" +import reflex_chakra as rc + + +def container(*children, **props): + """A fixed container based on a 960px grid.""" + # Enable override of default props. + props = ( + dict( + width="100%", + max_width="960px", + bg="white", + h="100%", + px=[4, 12], + margin="0 auto", + position="relative", + ) + | props + ) + return rc.box(*children, **props) diff --git a/examples/twitter/twitter/layouts/__init__.py b/examples/twitter/twitter/layouts/__init__.py new file mode 100644 index 0000000..401a24b --- /dev/null +++ b/examples/twitter/twitter/layouts/__init__.py @@ -0,0 +1,2 @@ +"""Re-export layouts.""" +from .auth import auth_layout diff --git a/examples/twitter/twitter/layouts/auth.py b/examples/twitter/twitter/layouts/auth.py new file mode 100644 index 0000000..61baf21 --- /dev/null +++ b/examples/twitter/twitter/layouts/auth.py @@ -0,0 +1,44 @@ +"""Shared auth layout.""" +import reflex_chakra as rc + +from ..components import container + + +def auth_layout(*args): + """The shared layout for the login and sign up pages.""" + return rc.box( + container( + rc.heading( + rc.span("Welcome to PySocial!"), + rc.span("Sign in or sign up to get started."), + display="flex", + flex_direction="column", + align_items="center", + text_align="center", + ), + rc.text( + "See the source code for this demo app ", + rc.link( + "here", + href="https://github.com/reflex-io/reflex-examples", + color="blue.500", + ), + ".", + color="gray.500", + font_weight="medium", + ), + *args, + border_top_radius="lg", + box_shadow="0 4px 60px 0 rgba(0, 0, 0, 0.08), 0 4px 16px 0 rgba(0, 0, 0, 0.08)", + display="flex", + flex_direction="column", + align_items="center", + py=12, + gap=4, + ), + h="100vh", + pt=16, + background="url(bg.svg)", + background_repeat="no-repeat", + background_size="cover", + ) diff --git a/examples/twitter/twitter/pages/__init__.py b/examples/twitter/twitter/pages/__init__.py new file mode 100644 index 0000000..98ea440 --- /dev/null +++ b/examples/twitter/twitter/pages/__init__.py @@ -0,0 +1,4 @@ +"""Re-export pages.""" +from .home import home +from .login import login +from .signup import signup diff --git a/examples/twitter/twitter/pages/home.py b/examples/twitter/twitter/pages/home.py new file mode 100644 index 0000000..ea888a0 --- /dev/null +++ b/examples/twitter/twitter/pages/home.py @@ -0,0 +1,210 @@ +"""The home page. This file includes examples abstracting complex UI into smaller components.""" +import reflex as rx +import reflex_chakra as rc +from twitter.state.base import State +from twitter.state.home import HomeState + +from ..components import container + + +def tab_button(name, href): + """A tab switcher button.""" + return rc.link( + rc.icon(tag="star", mr=2), + name, + display="inline-flex", + align_items="center", + py=3, + px=6, + href=href, + border="1px solid #eaeaea", + font_weight="semibold", + border_radius="full", + ) + + +def tabs(): + """The tab switcher displayed on the left.""" + return rc.box( + rc.vstack( + rc.heading("PySocial", size="md"), + tab_button("Home", "/"), + rc.box( + rc.heading("Followers", size="sm"), + rx.foreach( + HomeState.followers, + lambda follow: rc.vstack( + rc.hstack( + rc.avatar(name=follow.follower_username, size="sm"), + rc.text(follow.follower_username), + ), + padding="1em", + ), + ), + p=4, + border_radius="md", + border="1px solid #eaeaea", + ), + rc.button("Sign out", on_click=State.logout), + align_items="left", + gap=4, + ), + py=4, + ) + + +def sidebar(HomeState): + """The sidebar displayed on the right.""" + return rc.vstack( + rc.input( + on_change=HomeState.set_friend, + placeholder="Search users", + width="100%", + ), + rx.foreach( + HomeState.search_users, + lambda user: rc.vstack( + rc.hstack( + rc.avatar(name=user.username, size="sm"), + rc.text(user.username), + rc.spacer(), + rc.button( + rc.icon(tag="add"), + on_click=lambda: HomeState.follow_user(user.username), + ), + width="100%", + ), + py=2, + width="100%", + ), + ), + rc.box( + rc.heading("Following", size="sm"), + rx.foreach( + HomeState.following, + lambda follow: rc.vstack( + rc.hstack( + rc.avatar(name=follow.followed_username, size="sm"), + rc.text(follow.followed_username), + ), + padding="1em", + ), + ), + p=4, + border_radius="md", + border="1px solid #eaeaea", + w="100%", + ), + align_items="start", + gap=4, + h="100%", + py=4, + ) + + +def feed_header(HomeState): + """The header of the feed.""" + return rc.hstack( + rc.heading("Home", size="md"), + rc.input(on_change=HomeState.set_search, placeholder="Search tweets"), + justify="space-between", + p=4, + border_bottom="1px solid #ededed", + ) + + +def composer(HomeState): + """The composer for new tweets.""" + return rc.grid( + rc.vstack( + rc.avatar(size="md"), + p=4, + ), + rc.box( + rc.text_area( + w="100%", + border=0, + placeholder="What's happening?", + resize="none", + py=4, + px=0, + _focus={"border": 0, "outline": 0, "boxShadow": "none"}, + on_blur=HomeState.set_tweet, + ), + rc.hstack( + rc.button( + "Tweet", + on_click=HomeState.post_tweet, + bg="rgb(29 161 242)", + color="white", + border_radius="full", + ), + justify_content="flex-end", + border_top="1px solid #ededed", + px=4, + py=2, + ), + ), + grid_template_columns="1fr 5fr", + border_bottom="1px solid #ededed", + ) + + +def tweet(tweet): + """Display for an individual tweet in the feed.""" + return rc.grid( + rc.vstack( + rc.avatar(name=tweet.author, size="sm"), + ), + rc.box( + rc.text("@" + tweet.author, font_weight="bold"), + rc.text(tweet.content, width="100%"), + ), + grid_template_columns="1fr 5fr", + py=4, + gap=1, + border_bottom="1px solid #ededed", + ) + + +def feed(HomeState): + """The feed.""" + return rc.box( + feed_header(HomeState), + composer(HomeState), + rx.cond( + HomeState.tweets, + rx.foreach( + HomeState.tweets, + tweet, + ), + rc.vstack( + rc.button( + rc.icon( + tag="repeat", + mr=1, + ), + rc.text("Click to load tweets"), + on_click=HomeState.get_tweets, + ), + p=4, + ), + ), + border_x="1px solid #ededed", + h="100%", + ) + + +def home(): + """The home page.""" + return container( + rc.grid( + tabs(), + feed(HomeState), + sidebar(HomeState), + grid_template_columns="1fr 2fr 1fr", + h="100vh", + gap=4, + ), + max_width="1300px", + ) diff --git a/examples/twitter/twitter/pages/login.py b/examples/twitter/twitter/pages/login.py new file mode 100644 index 0000000..59068eb --- /dev/null +++ b/examples/twitter/twitter/pages/login.py @@ -0,0 +1,37 @@ +"""Login page. Uses auth_layout to render UI shared with the sign up page.""" +import reflex_chakra as rc +from twitter.layouts import auth_layout +from twitter.state.auth import AuthState + + +def login(): + """The login page.""" + return auth_layout( + rc.box( + rc.input(placeholder="Username", on_blur=AuthState.set_username, mb=4), + rc.input( + type_="password", + placeholder="Password", + on_blur=AuthState.set_password, + mb=4, + ), + rc.button( + "Log in", + on_click=AuthState.login, + bg="blue.500", + color="white", + _hover={"bg": "blue.600"}, + ), + align_items="left", + bg="white", + border="1px solid #eaeaea", + p=4, + max_width="400px", + border_radius="lg", + ), + rc.text( + "Don't have an account yet? ", + rc.link("Sign up here.", href="/signup", color="blue.500"), + color="gray.600", + ), + ) diff --git a/examples/twitter/twitter/pages/signup.py b/examples/twitter/twitter/pages/signup.py new file mode 100644 index 0000000..836648e --- /dev/null +++ b/examples/twitter/twitter/pages/signup.py @@ -0,0 +1,43 @@ +"""Sign up page. Uses auth_layout to render UI shared with the login page.""" +import reflex_chakra as rc +from twitter.layouts import auth_layout +from twitter.state.auth import AuthState + + +def signup(): + """The sign up page.""" + return auth_layout( + rc.box( + rc.input(placeholder="Username", on_blur=AuthState.set_username, mb=4), + rc.input( + type_="password", + placeholder="Password", + on_blur=AuthState.set_password, + mb=4, + ), + rc.input( + type_="password", + placeholder="Confirm password", + on_blur=AuthState.set_confirm_password, + mb=4, + ), + rc.button( + "Sign up", + on_click=AuthState.signup, + bg="blue.500", + color="white", + _hover={"bg": "blue.600"}, + ), + align_items="left", + bg="white", + border="1px solid #eaeaea", + p=4, + max_width="400px", + border_radius="lg", + ), + rc.text( + "Already have an account? ", + rc.link("Sign in here.", href="/", color="blue.500"), + color="gray.600", + ), + ) diff --git a/examples/twitter/twitter/state/auth.py b/examples/twitter/twitter/state/auth.py new file mode 100644 index 0000000..67da8a6 --- /dev/null +++ b/examples/twitter/twitter/state/auth.py @@ -0,0 +1,38 @@ +"""The authentication state.""" +import reflex as rx +from sqlmodel import select + +from .base import State, User + + +class AuthState(State): + """The authentication state for sign up and login page.""" + + username: str + password: str + confirm_password: str + + def signup(self): + """Sign up a user.""" + with rx.session() as session: + if self.password != self.confirm_password: + return rx.window_alert("Passwords do not match.") + if session.exec(select(User).where(User.username == self.username)).first(): + return rx.window_alert("Username already exists.") + self.user = User(username=self.username, password=self.password) + session.add(self.user) + session.expire_on_commit = False + session.commit() + return rx.redirect("/") + + def login(self): + """Log in a user.""" + with rx.session() as session: + user = session.exec( + select(User).where(User.username == self.username) + ).first() + if user and user.password == self.password: + self.user = user + return rx.redirect("/") + else: + return rx.window_alert("Invalid username or password.") diff --git a/examples/twitter/twitter/state/base.py b/examples/twitter/twitter/state/base.py new file mode 100644 index 0000000..d3eb32b --- /dev/null +++ b/examples/twitter/twitter/state/base.py @@ -0,0 +1,53 @@ +"""Base state for Twitter example. Schema is inspired by https://drawsql.app/templates/twitter.""" +from typing import Optional + +from sqlmodel import Field + +import reflex as rx + + +class Follows(rx.Model, table=True): + """A table of Follows. This is a many-to-many join table. + + See https://sqlmodel.tiangolo.com/tutorial/many-to-many/ for more information. + """ + + followed_username: str = Field(primary_key=True) + follower_username: str = Field(primary_key=True) + + +class User(rx.Model, table=True): + """A table of Users.""" + + username: str = Field() + password: str = Field() + + +class Tweet(rx.Model, table=True): + """A table of Tweets.""" + + content: str = Field() + created_at: str = Field() + + author: str = Field() + + +class State(rx.State): + """The base state for the app.""" + + user: Optional[User] = None + + def logout(self): + """Log out a user.""" + self.reset() + return rx.redirect("/") + + def check_login(self): + """Check if a user is logged in.""" + if not self.logged_in: + return rx.redirect("/login") + + @rx.var + def logged_in(self): + """Check if a user is logged in.""" + return self.user is not None diff --git a/examples/twitter/twitter/state/home.py b/examples/twitter/twitter/state/home.py new file mode 100644 index 0000000..36abe76 --- /dev/null +++ b/examples/twitter/twitter/state/home.py @@ -0,0 +1,94 @@ +"""The state for the home page.""" +from datetime import datetime + +import reflex as rx +from sqlmodel import select + +from .base import Follows, State, Tweet, User + + +class HomeState(State): + """The state for the home page.""" + + tweet: str + tweets: list[Tweet] = [] + + friend: str + search: str + + def post_tweet(self): + """Post a tweet.""" + if not self.logged_in: + return rx.window_alert("Please log in to post a tweet.") + with rx.session() as session: + tweet = Tweet( + author=self.user.username, + content=self.tweet, + created_at=datetime.now().strftime("%m/%d %H"), + ) + session.add(tweet) + session.commit() + return self.get_tweets() + + def get_tweets(self): + """Get tweets from the database.""" + with rx.session() as session: + if self.search: + self.tweets = session.exec( + select(Tweet).where(Tweet.content.contains(self.search)) + ).all()[::-1] + else: + self.tweets = session.exec(select(Tweet)).all()[::-1] + + def set_search(self, search): + """Set the search query.""" + self.search = search + return self.get_tweets() + + def follow_user(self, username): + """Follow a user.""" + with rx.session() as session: + friend = Follows( + follower_username=self.user.username, followed_username=username + ) + session.add(friend) + session.commit() + + @rx.var + def following(self) -> list[Follows]: + """Get a list of users the current user is following.""" + if self.logged_in: + with rx.session() as session: + return session.exec( + select(Follows).where( + Follows.follower_username == self.user.username + ) + ).all() + return [] + + @rx.var + def followers(self) -> list[Follows]: + """Get a list of users following the current user.""" + if self.logged_in: + with rx.session() as session: + return session.exec( + select(Follows).where( + Follows.followed_username == self.user.username + ) + ).all() + return [] + + @rx.var + def search_users(self) -> list[User]: + """Get a list of users matching the search query.""" + if self.friend != "": + with rx.session() as session: + current_username = self.user.username if self.user is not None else "" + users = session.exec( + select(User).where( + User.username.contains(self.friend), + User.username != current_username, + ) + ).all() + return users + return [] diff --git a/examples/twitter/twitter/twitter.py b/examples/twitter/twitter/twitter.py new file mode 100644 index 0000000..3c1af37 --- /dev/null +++ b/examples/twitter/twitter/twitter.py @@ -0,0 +1,10 @@ +"""Welcome to Reflex! This file outlines the steps to create a basic app.""" +import reflex as rx + +from .pages import home, login, signup +from .state.base import State + +app = rx.App() +app.add_page(login) +app.add_page(signup) +app.add_page(home, route="/", on_load=State.check_login())