549 lines
19 KiB
Python
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",
|
|
)
|