eptm_dashboard/eptm_dashboard/sidebar.py

400 lines
14 KiB
Python

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", "layout-dashboard"),
("Apprentis", "/fiche", "user"),
("Classes", "/classe", "users"),
]
_ADMIN_PAGES = [
("Escada", "/escada", "globe"),
("Cron", "/cron", "alarm-clock"),
("Logs", "/logs", "file-text"),
("Utilisateurs", "/users", "user-cog"),
("Parametres", "/params", "settings"),
]
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.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",
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,
on_click=click_handler,
text_decoration="none",
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 _avatar_or_photo(size: str = "2") -> rx.Component:
img_size = "32px" if size == "2" else "28px"
return rx.cond(
AuthState.photo_url != "",
rx.image(
src=AuthState.photo_url,
width=img_size,
height=img_size,
border_radius="50%",
object_fit="cover",
border="1.5px solid var(--gray-5)",
flex_shrink="0",
),
rx.image(
src="/default_avatar.svg",
width=img_size,
height=img_size,
border_radius="50%",
flex_shrink="0",
),
)
def _user_widget(collapsed: bool = False) -> rx.Component:
if collapsed:
return rx.tooltip(
rx.vstack(
_avatar_or_photo(size="2"),
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(
_avatar_or_photo(size="2"),
rx.vstack(
rx.text(AuthState.name, size="2", font_weight="600",
color=_TEXT, 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(
# 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="1", width="100%",
padding_x=rx.cond(AuthState.sidebar_collapsed, "0.5rem", "0"),
padding_y="0.5rem",
),
_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.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",
),
_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%",
),
background_color=_BG,
width="100%",
max_height=f"calc(100vh - {TOPBAR_H})",
overflow_y="auto",
),
),
class_name="topbar-mobile",
background_color=_BG,
border_bottom=f"1px solid {_BORDER}",
position="fixed",
top="0", left="0", right="0",
width="100%",
z_index="200",
)
# ── Layout wrapper ───────────────────────────────────────────────────────────
def layout(content: rx.Component) -> rx.Component:
return rx.box(
sidebar(),
_mobile_topbar(),
rx.box(
content,
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})",
),
max_width=rx.cond(
AuthState.sidebar_collapsed,
f"calc(100% - {RAIL_W})",
f"calc(100% - {FULL_W})",
),
overflow_x="hidden",
transition="margin-left 0.22s ease, width 0.22s ease",
box_sizing="border-box",
),
width="100%",
height="100vh",
overflow="hidden",
)