eptm_dashboard/eptm_dashboard/sidebar.py
2026-05-11 14:45:42 +02:00

549 lines
19 KiB
Python

import reflex as rx
from .state import AuthState
from .components import scan_docs
# Liste des sections de doc (scan au module-load — un restart suffit pour
# détecter de nouveaux fichiers).
_DOC_SECTIONS = scan_docs()
FULL_W = "240px"
RAIL_W = "68px"
TOPBAR_H = "56px"
# Sidebar palette : couleurs neutres locales + tokens de marque (cf. responsive.css).
_BG = "#f8f9fa" # sidebar background (light)
_BORDER = "#e5e7eb" # subtle separator
_TEXT = "#4b5563" # inactive text
_TEXT_MUTED = "#9ca3af" # muted labels
_HOVER_BG = "#f3f4f6"
_USER_BG = "#f3f4f6" # slightly darker user section
# Tokens dynamiques (changent selon le thème user)
_ACTIVE_BG = "var(--brand-primary-tint)"
_ACTIVE_CLR = "var(--brand-primary-light)"
_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"),
("Paramètres", "/params", "settings"),
("Purger classe","/purge", "trash-2"),
]
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 _doc_subitem(title: str, slug: str, mobile: bool = False) -> rx.Component:
"""Lien vers une section de doc — navigue vers /doc + sélectionne la section."""
# Import local pour éviter le cycle sidebar ↔ pages.doc
from .pages.doc import DocState
is_active = (
(AuthState.router.page.path == "/doc")
& (DocState.selected_slug == slug)
)
on_click_actions = [DocState.select_section(slug), rx.redirect("/doc")]
if mobile:
on_click_actions.append(AuthState.close_mobile_menu)
return rx.box(
rx.text(
title,
size="2",
color=rx.cond(is_active, "#ffffff", _TEXT),
font_weight=rx.cond(is_active, "600", "400"),
),
on_click=on_click_actions,
cursor="pointer",
padding="0.4rem 0.75rem 0.4rem 2rem",
border_radius="0 6px 6px 0",
background_color=rx.cond(is_active, _ACTIVE_BG, "transparent"),
_hover={"background_color": _HOVER_BG},
width="100%",
class_name="smooth-transition",
)
def _doc_section(mobile: bool = False) -> rx.Component:
if not _DOC_SECTIONS:
return rx.fragment()
return rx.cond(
AuthState.sidebar_collapsed if not mobile else rx.Var.create(False),
# Rail mode : icône simple vers /doc, sans sous-menu
rx.box(
_nav_rail("Documentation", "/doc", "book-open"),
padding_x="0.5rem", padding_y="0.25rem",
width="100%",
),
# Full mode : header cliquable + sous-items
rx.vstack(
rx.button(
rx.hstack(
rx.icon("book-open", size=17, color=_TEXT, flex_shrink="0"),
rx.text("Documentation", size="2", color=_TEXT, weight="medium"),
rx.spacer(),
rx.icon(
rx.cond(AuthState.doc_expanded, "chevron-up", "chevron-down"),
size=14, color=_TEXT_MUTED,
),
spacing="3", align="center", width="100%",
),
on_click=AuthState.toggle_doc,
variant="ghost",
width="100%",
size="2",
padding_x="0.75rem",
padding_y="0.5rem",
color=_TEXT,
_hover={"background_color": _HOVER_BG},
cursor="pointer",
justify="start",
),
rx.cond(
AuthState.doc_expanded,
rx.vstack(
*[_doc_subitem(s["title"], s["slug"], mobile) for s in _DOC_SECTIONS],
spacing="0", width="100%",
),
),
spacing="0", width="100%",
padding_x="0.5rem", padding_y="0.1rem",
),
)
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_menu_items() -> rx.Component:
"""Items du dropdown : Mon profil + Déconnexion."""
return rx.vstack(
rx.link(
rx.flex(
rx.icon("user", size=15, color=_TEXT),
rx.text("Mon profil", size="2"),
gap="0.5rem", align="center", padding="0.4rem 0.75rem",
width="100%", _hover={"background_color": _HOVER_BG},
cursor="pointer", border_radius="4px",
),
href="/profile",
text_decoration="none",
color="inherit",
width="100%",
),
rx.flex(
rx.icon("log-out", size=15, color=_TEXT),
rx.text("Déconnexion", size="2"),
gap="0.5rem", align="center", padding="0.4rem 0.75rem",
width="100%", _hover={"background_color": _HOVER_BG},
cursor="pointer", border_radius="4px",
on_click=AuthState.logout,
),
spacing="0", width="100%",
)
def _user_widget(collapsed: bool = False) -> rx.Component:
if collapsed:
return rx.popover.root(
rx.popover.trigger(
rx.tooltip(
rx.box(
_avatar_or_photo(size="2"),
cursor="pointer",
display="flex",
justify_content="center",
width="100%",
),
content=AuthState.name,
side="right",
),
),
rx.popover.content(
_user_menu_items(),
min_width="180px",
padding="0.4rem",
side="right",
align="end",
),
)
return rx.popover.root(
rx.popover.trigger(
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("chevron-up", size=14, color=_TEXT_MUTED),
spacing="2", align="center", width="100%", overflow="hidden",
cursor="pointer",
padding="0.25rem 0.5rem",
border_radius="6px",
_hover={"background_color": _HOVER_BG},
class_name="smooth-transition",
),
),
rx.popover.content(
_user_menu_items(),
min_width="200px",
padding="0.4rem",
side="top",
align="end",
),
)
# ── 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(),
_doc_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="48px", object_fit="contain"),
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),
_doc_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 ───────────────────────────────────────────────────────────
_KEYBOARD_SHORTCUTS_JS = """
(() => {
if (window.__eptmShortcutsInstalled) return;
window.__eptmShortcutsInstalled = true;
document.addEventListener('keydown', (e) => {
const tag = (document.activeElement && document.activeElement.tagName) || '';
const isTyping = ['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)
|| document.activeElement?.isContentEditable;
// '/' = focus le sélecteur de recherche (apprenti/classe) sur la page courante
if (e.key === '/' && !isTyping) {
const trigger = document.querySelector(
'[data-shortcut="apprenti-search"], [data-shortcut="class-search"]'
);
if (trigger) {
e.preventDefault();
trigger.click();
}
}
});
})();
"""
def layout(content: rx.Component) -> rx.Component:
return rx.box(
sidebar(),
_mobile_topbar(),
rx.box(
content,
class_name=rx.cond(
AuthState.sidebar_collapsed,
"content-area sidebar-collapsed",
"content-area",
),
padding=rx.cond(AuthState.sidebar_collapsed, "1rem", "1.5rem"),
background_color="var(--gray-2)",
overflow_x="hidden",
transition="margin-left 0.22s ease, width 0.22s ease",
box_sizing="border-box",
),
rx.script(_KEYBOARD_SHORTCUTS_JS),
width="100%",
height="100vh",
overflow="hidden",
)