From 360e8e02a7f534c8cb2edad794b28bbdfabff02a Mon Sep 17 00:00:00 2001 From: Julien Balet Date: Fri, 8 May 2026 00:34:51 +0200 Subject: [PATCH] main layout ok --- assets/favicon.png | Bin 0 -> 17535 bytes assets/favicon.svg | 58 +++++ assets/responsive.css | 64 ++++++ docker-compose.dev.yml | 2 + eptm_dashboard/eptm_dashboard.py | 31 ++- eptm_dashboard/pages/accueil.py | 48 ++-- eptm_dashboard/pages/login.py | 72 +++--- eptm_dashboard/sidebar.py | 381 +++++++++++++++++++++++++------ eptm_dashboard/state.py | 27 ++- rxconfig.py | 10 + 10 files changed, 565 insertions(+), 128 deletions(-) create mode 100644 assets/favicon.png create mode 100644 assets/favicon.svg create mode 100644 assets/responsive.css diff --git a/assets/favicon.png b/assets/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..e13d8e532f2aadf6384eb166017cfb61f34d3c73 GIT binary patch literal 17535 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 literal 0 HcmV?d00001 diff --git a/assets/favicon.svg b/assets/favicon.svg new file mode 100644 index 0000000..becf5e5 --- /dev/null +++ b/assets/favicon.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + E + + + + + + + + + + + EPTM + diff --git a/assets/responsive.css b/assets/responsive.css new file mode 100644 index 0000000..4c9cfb2 --- /dev/null +++ b/assets/responsive.css @@ -0,0 +1,64 @@ +/* Reset default margins and padding */ +* { + box-sizing: border-box; +} + +body, html { + width: 100%; + max-width: 100%; + margin: 0; + padding: 0; + height: 100%; + overflow: hidden; +} + +#root, #__next { + height: 100%; +} + +/* App shell: viewport-bounded with internal scroll on content */ +.content-area { + width: 100%; + max-width: 100%; + height: 100vh; + overflow-y: auto; + overflow-x: hidden; +} + +/* Mobile: hide desktop sidebar, account for fixed topbar (56px) */ +@media (max-width: 767px) { + .sidebar-desktop { display: none !important; } + .content-area { + margin-left: 0 !important; + width: 100% !important; + padding-top: calc(56px + 0.75rem) !important; + padding-left: 0.75rem !important; + padding-right: 0.75rem !important; + padding-bottom: 0.75rem !important; + } +} + +/* Tablet */ +@media (min-width: 768px) and (max-width: 1024px) { + .content-area { + padding: 1rem !important; + } +} + +/* Desktop: hide mobile topbar */ +@media (min-width: 768px) { + .topbar-mobile { display: none !important; } +} + +/* Ensure responsive images and content */ +img { + max-width: 100%; + height: auto; + display: block; +} + +/* Ensure flex containers wrap properly */ +.content-area > * { + min-width: 0; + max-width: 100%; +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 3ba05cf..22a1acd 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -9,8 +9,10 @@ services: - "8001:8001" volumes: - ./eptm_dashboard:/app/eptm_dashboard + - ./rxconfig.py:/app/rxconfig.py - ./data:/data - ./logs:/logs + - ./assets:/app/assets env_file: - .env.prod environment: diff --git a/eptm_dashboard/eptm_dashboard.py b/eptm_dashboard/eptm_dashboard.py index 36f97cb..735afce 100644 --- a/eptm_dashboard/eptm_dashboard.py +++ b/eptm_dashboard/eptm_dashboard.py @@ -12,16 +12,23 @@ from .pages.logs import logs_page from .pages.users import users_page from .pages.params import params_page -app = rx.App() +TITLE = "EPTM Dashboard" -app.add_page(login_page, route="/login") -app.add_page(accueil_page, route="/accueil", on_load=AccueilState.load_data) -app.add_page(traiter_page, route="/traiter", on_load=AuthState.check_auth) -app.add_page(fiche_page, route="/fiche", on_load=FicheState.load_data) -app.add_page(classe_page, route="/classe", on_load=AuthState.check_auth) -app.add_page(import_page_page, route="/import", on_load=AuthState.check_auth) -app.add_page(escada_page, route="/escada", on_load=AuthState.check_auth) -app.add_page(export_page, route="/export", on_load=AuthState.check_auth) -app.add_page(logs_page, route="/logs", on_load=AuthState.check_auth) -app.add_page(users_page, route="/users", on_load=AuthState.check_auth) -app.add_page(params_page, route="/params", on_load=AuthState.check_auth) +app = rx.App( + stylesheets=["/responsive.css"], + head_components=[ + rx.el.link(rel="icon", type="image/png", href="/favicon.png"), + ], +) + +app.add_page(login_page, route="/login", title=TITLE) +app.add_page(accueil_page, route="/accueil", on_load=AccueilState.load_data, title=TITLE) +app.add_page(traiter_page, route="/traiter", on_load=AuthState.check_auth, title=TITLE) +app.add_page(fiche_page, route="/fiche", on_load=FicheState.load_data, title=TITLE) +app.add_page(classe_page, route="/classe", on_load=AuthState.check_auth, title=TITLE) +app.add_page(import_page_page, route="/import", on_load=AuthState.check_auth, title=TITLE) +app.add_page(escada_page, route="/escada", on_load=AuthState.check_auth, title=TITLE) +app.add_page(export_page, route="/export", on_load=AuthState.check_auth, title=TITLE) +app.add_page(logs_page, route="/logs", on_load=AuthState.check_auth, title=TITLE) +app.add_page(users_page, route="/users", on_load=AuthState.check_auth, title=TITLE) +app.add_page(params_page, route="/params", on_load=AuthState.check_auth, title=TITLE) diff --git a/eptm_dashboard/pages/accueil.py b/eptm_dashboard/pages/accueil.py index 32a5611..aad777d 100644 --- a/eptm_dashboard/pages/accueil.py +++ b/eptm_dashboard/pages/accueil.py @@ -52,7 +52,8 @@ def _kpi_card(label: str, value: rx.Var) -> rx.Component: border_radius="8px", padding="0.75rem 1rem", flex="1", - min_width="120px", + min_width="80px", + width="100%", ) @@ -105,36 +106,41 @@ def accueil_page() -> rx.Component: spacing="3", width="100%", wrap="wrap", + align_items="stretch", ), rx.divider(), rx.heading("🚨 Avis de sanction — quota atteint", size="5"), - rx.cond( - AccueilState.sanctions.length() == 0, - rx.box( - rx.text("✓ Aucun apprenti n'a atteint le quota de 5 absences.", - color="#2e7d32", size="2"), - background_color="#f1f8f1", - border="1px solid #c8e6c9", - border_radius="6px", - padding="0.75rem 1rem", - ), - rx.vstack( + rx.box( + rx.cond( + AccueilState.sanctions.length() == 0, rx.box( - rx.text("⚠ ", AccueilState.sanctions.length(), - " apprenti(s) en avis de sanction", - color="#B71C1C", size="2", font_weight="600"), - background_color="#fff5f5", - border="1px solid #ffcccc", + rx.text("✓ Aucun apprenti n'a atteint le quota de 5 absences.", + color="#2e7d32", size="2"), + background_color="#f1f8f1", + border="1px solid #c8e6c9", border_radius="6px", padding="0.75rem 1rem", width="100%", ), - rx.foreach(AccueilState.sanctions, _sanction_card), - width="100%", - spacing="1", + rx.vstack( + rx.box( + rx.text("⚠ ", AccueilState.sanctions.length(), + " apprenti(s) en avis de sanction", + color="#B71C1C", size="2", font_weight="600"), + background_color="#fff5f5", + border="1px solid #ffcccc", + border_radius="6px", + padding="0.75rem 1rem", + width="100%", + ), + rx.foreach(AccueilState.sanctions, _sanction_card), + width="100%", + spacing="1", + ), ), + width="100%", ), rx.divider(), @@ -147,10 +153,12 @@ def accueil_page() -> rx.Component: border="1px solid #90caf9", border_radius="6px", padding="0.75rem 1rem", + width="100%", ), spacing="5", width="100%", + max_width="100%", align="start", padding_bottom="2rem", ) diff --git a/eptm_dashboard/pages/login.py b/eptm_dashboard/pages/login.py index 66ac3ca..104c9b5 100644 --- a/eptm_dashboard/pages/login.py +++ b/eptm_dashboard/pages/login.py @@ -4,46 +4,56 @@ from ..state import AuthState def login_page() -> rx.Component: return rx.center( - rx.vstack( - rx.image(src="/logo.png", width="160px"), - rx.heading("EPTM Dashboard", size="5", color="#37474f"), - rx.cond( - AuthState.login_error != "", - rx.box( - rx.text(AuthState.login_error, color="red", size="2"), - padding="0.5rem 1rem", - background_color="#fff5f5", - border="1px solid #ffcccc", - border_radius="6px", + rx.form( + rx.vstack( + rx.center( + rx.image(src="/logo.png", width="320px", height="auto"), width="100%", ), - ), - rx.input( - placeholder="Identifiant", - value=AuthState.login_user, - on_change=AuthState.set_login_user, + rx.cond( + AuthState.login_error != "", + rx.box( + rx.text(AuthState.login_error, color="red", size="2"), + padding="0.5rem 1rem", + background_color="#fff5f5", + border="1px solid #ffcccc", + border_radius="6px", + width="100%", + ), + ), + rx.input( + name="username", + placeholder="Identifiant", + value=AuthState.login_user, + on_change=AuthState.set_login_user, + width="100%", + ), + rx.input( + name="password", + placeholder="Mot de passe", + type="password", + value=AuthState.login_pass, + on_change=AuthState.set_login_pass, + width="100%", + ), + rx.button( + "Se connecter", + type="submit", + width="100%", + color_scheme="indigo", + ), + spacing="3", width="100%", + align="center", ), - rx.input( - placeholder="Mot de passe", - type="password", - value=AuthState.login_pass, - on_change=AuthState.set_login_pass, - width="100%", - ), - rx.button( - "Se connecter", - on_click=AuthState.handle_login, - width="100%", - color_scheme="indigo", - ), - spacing="3", - width="350px", + on_submit=AuthState.handle_login, + width="420px", padding="2rem", background_color="white", border_radius="8px", box_shadow="0 2px 16px rgba(0,0,0,0.08)", ), + width="100%", height="100vh", background_color="#f8f9fa", ) diff --git a/eptm_dashboard/sidebar.py b/eptm_dashboard/sidebar.py index 4308413..0cbb5e7 100644 --- a/eptm_dashboard/sidebar.py +++ b/eptm_dashboard/sidebar.py @@ -1,9 +1,23 @@ import reflex as rx from .state import AuthState +FULL_W = "240px" +RAIL_W = "68px" +TOPBAR_H = "56px" + +# EPTM brand palette (logo: noir #000 + rouge #e00010) +_BG = "#f8f9fa" # sidebar background (light) +_BORDER = "#e5e7eb" # subtle separator +_TEXT = "#4b5563" # inactive text +_TEXT_MUTED = "#9ca3af" # muted labels +_ACTIVE_BG = "rgba(220, 0, 14, 0.18)" # EPTM red tint +_ACTIVE_CLR = "#ff4a54" # bright red on dark bg +_HOVER_BG = "#f3f4f6" +_USER_BG = "#f3f4f6" # slightly darker user section + _PAGES = [ - ("Tableau de bord", "/accueil", "house"), - ("À traiter", "/traiter", "triangle-alert"), + ("Tableau de bord", "/accueil", "layout-dashboard"), + ("A traiter", "/traiter", "triangle-alert"), ("Fiche apprenti", "/fiche", "user"), ("Vue classe", "/classe", "users"), ("Import", "/import", "upload"), @@ -14,109 +28,348 @@ _PAGES = [ _ADMIN_PAGES = [ ("Logs", "/logs", "file-text"), ("Utilisateurs", "/users", "user-cog"), - ("Paramètres", "/params", "settings"), + ("Parametres", "/params", "settings"), ] -def _nav_item(label: str, href: str, icon: str) -> rx.Component: +def _nav_full(label: str, href: str, icon_name: str, close_menu: bool = False) -> rx.Component: + is_active = AuthState.router.page.path == href + click_handler = AuthState.close_mobile_menu if close_menu else None return rx.link( rx.hstack( - rx.icon(icon, size=16, color="#9e9e9e"), - rx.text(label, size="2", color="#555555"), + rx.box( + width="3px", + height="100%", + min_height="20px", + border_radius="0 2px 2px 0", + background_color=rx.cond(is_active, _ACTIVE_CLR, "transparent"), + position="absolute", + left="0", + top="0", + ), + rx.icon( + icon_name, size=17, + color=rx.cond(is_active, _ACTIVE_CLR, _TEXT), + flex_shrink="0", + ), + rx.text( + label, size="2", + color=rx.cond(is_active, "#ffffff", _TEXT), + font_weight=rx.cond(is_active, "600", "400"), + white_space="nowrap", + overflow="hidden", + ), spacing="3", align="center", - padding_x="1rem", - padding_y="0.55rem", width="100%", + padding_x="0.75rem", + padding_y="0.5rem", + border_radius="0 6px 6px 0", + background_color=rx.cond(is_active, _ACTIVE_BG, "transparent"), + _hover={"background_color": _HOVER_BG}, + position="relative", ), href=href, - width="100%", + on_click=click_handler, text_decoration="none", - _hover={"background_color": "#f8f9fa"}, + width="100%", display="block", ) +def _nav_rail(label: str, href: str, icon_name: str) -> rx.Component: + is_active = AuthState.router.page.path == href + return rx.tooltip( + rx.link( + rx.box( + rx.icon(icon_name, size=20, + color=rx.cond(is_active, _ACTIVE_CLR, _TEXT)), + width="100%", + display="flex", + align_items="center", + justify_content="center", + padding_y="0.6rem", + border_radius="6px", + background_color=rx.cond(is_active, _ACTIVE_BG, "transparent"), + border_left=rx.cond(is_active, f"3px solid {_ACTIVE_CLR}", "3px solid transparent"), + _hover={"background_color": _HOVER_BG}, + ), + href=href, + text_decoration="none", + width="100%", + display="block", + ), + content=label, + side="right", + ) + + +def _nav_item(label: str, href: str, icon_name: str) -> rx.Component: + return rx.cond( + AuthState.sidebar_collapsed, + _nav_rail(label, href, icon_name), + _nav_full(label, href, icon_name), + ) + + +def _admin_section(mobile: bool = False) -> rx.Component: + return rx.cond( + AuthState.role == "admin", + rx.vstack( + rx.box(height="1px", width="100%", background_color=_BORDER), + rx.cond( + AuthState.sidebar_collapsed if not mobile else rx.Var.create(False), + # Rail mode + rx.vstack( + *[_nav_rail(l, h, i) for l, h, i in _ADMIN_PAGES], + spacing="1", width="100%", + padding_x="0.5rem", padding_y="0.5rem", + ), + # Full mode with collapsible + rx.vstack( + rx.button( + rx.hstack( + rx.icon("shield", size=13, color=_TEXT_MUTED), + rx.text("Admin", size="1", color=_TEXT_MUTED, + font_weight="600", letter_spacing="0.1em"), + rx.spacer(), + rx.icon( + rx.cond(AuthState.admin_expanded, "chevron-up", "chevron-down"), + size=13, color=_TEXT_MUTED, + ), + spacing="2", align="center", width="100%", + ), + on_click=AuthState.toggle_admin, + variant="ghost", + width="100%", + size="1", + padding_x="0.75rem", + padding_y="0.4rem", + color=_TEXT_MUTED, + _hover={"background_color": _HOVER_BG}, + cursor="pointer", + ), + rx.cond( + AuthState.admin_expanded, + rx.vstack( + *[ + _nav_full(l, h, i, close_menu=mobile) + for l, h, i in _ADMIN_PAGES + ], + spacing="1", width="100%", + ), + ), + spacing="0", width="100%", + padding_x="0.75rem", padding_y="0.25rem", + ), + ), + spacing="0", width="100%", + ), + ) + + +def _user_widget(collapsed: bool = False) -> rx.Component: + if collapsed: + return rx.tooltip( + rx.vstack( + rx.avatar(fallback=AuthState.name_initials, size="2", + color_scheme="ruby", radius="full"), + rx.icon_button( + rx.icon("log-out", size=14), + on_click=AuthState.logout, + variant="ghost", size="1", cursor="pointer", + ), + spacing="2", align="center", width="100%", + ), + content=AuthState.name, + side="right", + ) + return rx.hstack( + rx.avatar(fallback=AuthState.name_initials, size="2", + color_scheme="ruby", radius="full"), + rx.vstack( + rx.text(AuthState.name, size="2", font_weight="600", + color="#f3f4f6", white_space="nowrap", overflow="hidden"), + rx.text(AuthState.role, size="1", color=_TEXT_MUTED), + spacing="0", align="start", overflow="hidden", flex="1", + ), + rx.icon_button( + rx.icon("log-out", size=14), + on_click=AuthState.logout, + variant="ghost", size="1", cursor="pointer", + ), + spacing="2", align="center", width="100%", overflow="hidden", + ) + + +# ── Desktop sidebar ────────────────────────────────────────────────────────── + def sidebar() -> rx.Component: return rx.box( rx.vstack( - rx.box( - rx.image(src="/logo.png", width="140px"), - text_align="center", - padding="1rem", - border_bottom="1px solid #dee2e6", - width="100%", + # Header: logo + toggle + rx.hstack( + rx.cond( + AuthState.sidebar_collapsed, + rx.box(flex="1"), + rx.box( + rx.image(src="/logo.png", height="112px", + object_fit="contain", max_width="160px", width="100%"), + flex="1", + display="flex", + align_items="center", + justify_content="center", + min_width="0", + ), + ), + rx.icon_button( + rx.cond( + AuthState.sidebar_collapsed, + rx.icon("panel-left-open", size=16), + rx.icon("panel-left-close", size=16), + ), + on_click=AuthState.toggle_sidebar, + variant="ghost", size="2", + color=_TEXT, cursor="pointer", flex_shrink="0", + ), + width="100%", align="center", + padding_y="0.75rem", + padding_x=rx.cond(AuthState.sidebar_collapsed, "0.5rem", "0.75rem"), ), + + rx.box(height="1px", width="100%", background_color=_BORDER), + + # Nav rx.vstack( *[_nav_item(l, h, i) for l, h, i in _PAGES], - spacing="0", - width="100%", + spacing="1", width="100%", + padding_x=rx.cond(AuthState.sidebar_collapsed, "0.5rem", "0"), padding_y="0.5rem", ), - rx.cond( - AuthState.role == "admin", - rx.vstack( - rx.box( - rx.text( - "ADMIN", size="1", color="#9e9e9e", - font_weight="700", letter_spacing="0.12em", - ), - padding_x="1rem", - padding_top="0.75rem", - padding_bottom="0.25rem", - ), - *[_nav_item(l, h, i) for l, h, i in _ADMIN_PAGES], - spacing="0", - width="100%", + + _admin_section(), + rx.spacer(), + + # User + rx.box(height="1px", width="100%", background_color=_BORDER), + rx.box( + rx.cond( + AuthState.sidebar_collapsed, + _user_widget(collapsed=True), + _user_widget(collapsed=False), ), + padding_y="0.75rem", + padding_x=rx.cond(AuthState.sidebar_collapsed, "0.5rem", "0.75rem"), + width="100%", + background_color=_USER_BG, + ), + + height="100vh", width="100%", + spacing="0", align="start", + overflow_y="auto", overflow_x="hidden", + ), + class_name="sidebar-desktop", + background_color=_BG, + border_right=f"1px solid {_BORDER}", + position="fixed", + left="0", top="0", + height="100vh", + width=rx.cond(AuthState.sidebar_collapsed, RAIL_W, FULL_W), + transition="width 0.22s ease", + z_index="100", + overflow="hidden", + ) + + +# ── Mobile top bar ─────────────────────────────────────────────────────────── + +def _mobile_topbar() -> rx.Component: + return rx.box( + # Bar row + rx.hstack( + rx.box( + rx.image(src="/logo.png", height="40px", object_fit="contain"), + background_color="white", + border_radius="5px", + padding="4px 8px", + display="flex", + align_items="center", + justify_content="center", ), rx.spacer(), + rx.icon_button( + rx.cond( + AuthState.mobile_menu_open, + rx.icon("x", size=20), + rx.icon("menu", size=20), + ), + on_click=AuthState.toggle_mobile_menu, + variant="ghost", size="2", + color=_TEXT, cursor="pointer", + ), + width="100%", align="center", + padding_x="1rem", + height=TOPBAR_H, + ), + + # Dropdown + rx.cond( + AuthState.mobile_menu_open, rx.box( rx.vstack( - rx.text(AuthState.name, size="2", font_weight="600", color="#37474f"), - rx.text(AuthState.role, size="1", color="#9e9e9e"), - rx.button( - "↩ Déconnexion", - on_click=AuthState.logout, - size="1", - variant="outline", - color_scheme="gray", - width="100%", + rx.box(height="1px", width="100%", background_color=_BORDER), + rx.vstack( + *[_nav_full(l, h, i, close_menu=True) for l, h, i in _PAGES], + spacing="1", width="100%", + padding_x="0", padding_y="0.5rem", ), - spacing="2", - width="100%", + _admin_section(mobile=True), + rx.box(height="1px", width="100%", background_color=_BORDER), + rx.box( + _user_widget(collapsed=False), + padding_x="0.75rem", padding_y="0.65rem", + background_color=_USER_BG, width="100%", + ), + spacing="0", width="100%", ), - padding="1rem", - border_top="1px solid #dee2e6", + background_color=_BG, width="100%", + max_height=f"calc(100vh - {TOPBAR_H})", + overflow_y="auto", ), - height="100vh", - width="240px", - spacing="0", - align="start", ), - background_color="white", - border_right="1px solid #dee2e6", + + class_name="topbar-mobile", + background_color=_BG, + border_bottom=f"1px solid {_BORDER}", position="fixed", - left="0", - top="0", - height="100vh", - z_index="100", + top="0", left="0", right="0", + width="100%", + z_index="200", ) +# ── Layout wrapper ─────────────────────────────────────────────────────────── + def layout(content: rx.Component) -> rx.Component: - return rx.hstack( + return rx.box( sidebar(), + _mobile_topbar(), rx.box( content, - margin_left="240px", - padding="2rem", - width="100%", - min_height="100vh", - background_color="#f8f9fa", + class_name="content-area", + padding=rx.cond(AuthState.sidebar_collapsed, "1rem", "1.5rem"), + background_color="var(--gray-2)", + margin_left=rx.cond(AuthState.sidebar_collapsed, RAIL_W, FULL_W), + width=rx.cond( + AuthState.sidebar_collapsed, + f"calc(100% - {RAIL_W})", + f"calc(100% - {FULL_W})", + ), + transition="margin-left 0.22s ease, width 0.22s ease", + box_sizing="border-box", ), - spacing="0", - align="start", width="100%", + height="100vh", + overflow="hidden", ) diff --git a/eptm_dashboard/state.py b/eptm_dashboard/state.py index eefbb6e..f2351dd 100644 --- a/eptm_dashboard/state.py +++ b/eptm_dashboard/state.py @@ -17,6 +17,31 @@ class AuthState(rx.State): login_pass: str = "" login_error: str = "" + sidebar_collapsed: bool = False + mobile_menu_open: bool = False + admin_expanded: bool = True + + @rx.var + def name_initials(self) -> str: + if not self.name: + return "?" + parts = self.name.split() + if len(parts) >= 2: + return (parts[0][0] + parts[1][0]).upper() + return self.name[:2].upper() + + def toggle_sidebar(self): + self.sidebar_collapsed = not self.sidebar_collapsed + + def toggle_mobile_menu(self): + self.mobile_menu_open = not self.mobile_menu_open + + def close_mobile_menu(self): + self.mobile_menu_open = False + + def toggle_admin(self): + self.admin_expanded = not self.admin_expanded + def set_login_user(self, value: str): self.login_user = value @@ -27,7 +52,7 @@ class AuthState(rx.State): if not self.authenticated: return rx.redirect("/login") - def handle_login(self): + def handle_login(self, form_data: dict | None = None): self.login_error = "" users = self._load_users() user = users.get(self.login_user) diff --git a/rxconfig.py b/rxconfig.py index 9c8940f..6ac7538 100644 --- a/rxconfig.py +++ b/rxconfig.py @@ -8,5 +8,15 @@ config = rx.Config( frontend_port=int(os.getenv("FRONTEND_PORT", "3000")), backend_port=int(os.getenv("BACKEND_PORT", "8000")), vite_allowed_hosts=True, + plugins=[ + rx.plugins.RadixThemesPlugin( + theme=rx.theme( + appearance="inherit", + accent_color="red", + radius="medium", + scaling="95%", + ) + ), + ], disable_plugins=[SitemapPlugin], )