Three major features implemented:
1. Event Detail Slide-Over Panel
- Click timeline events to view details in slide-over
- New /events/{event_id} route and event_detail.py template
- Type-specific payload rendering for all event types
2. Toast System Refactor
- Switch from custom addEventListener to FastHTML's add_toast()
- Replace HX-Trigger headers with session-based toasts
- Add event links in toast messages
- Replace addEventListener with hx_on_* in templates
3. Checkbox Selection for Animal Subsets
- New animal_select.py component with checkbox list
- New /api/compute-hash and /api/selection-preview endpoints
- Add subset_mode support to SelectionContext validation
- Update 5 forms: outcome, move, tag-add, tag-end, attrs
- Users can select specific animals from filtered results
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
280 lines
8.5 KiB
Python
280 lines
8.5 KiB
Python
# ABOUTME: Responsive sidebar and menu drawer components for AnimalTrack.
|
|
# ABOUTME: Desktop shows persistent sidebar, mobile shows slide-out drawer.
|
|
|
|
from fasthtml.common import A, Button, Div, Nav, Script, Span, Style
|
|
from fasthtml.svg import Path, Svg
|
|
|
|
from animaltrack.models.reference import UserRole
|
|
from animaltrack.web.templates.icons import EggIcon, FeedIcon, MoveIcon
|
|
|
|
|
|
def SidebarStyles(): # noqa: N802
|
|
"""CSS styles for sidebar and menu drawer - include in page head."""
|
|
return Style("""
|
|
/* Desktop sidebar - fixed left */
|
|
#desktop-sidebar {
|
|
transition: transform 0.3s ease-out;
|
|
}
|
|
|
|
/* Mobile menu drawer - slides from right */
|
|
#menu-drawer {
|
|
transform: translateX(100%);
|
|
transition: transform 0.3s ease-out;
|
|
}
|
|
|
|
#menu-drawer.open {
|
|
transform: translateX(0);
|
|
}
|
|
|
|
/* Backdrop overlay */
|
|
#menu-backdrop {
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transition: opacity 0.3s ease-out;
|
|
}
|
|
|
|
#menu-backdrop.open {
|
|
opacity: 1;
|
|
pointer-events: auto;
|
|
}
|
|
|
|
/* Active nav item indicator */
|
|
.sidebar-nav-active {
|
|
position: relative;
|
|
}
|
|
|
|
.sidebar-nav-active::before {
|
|
content: '';
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
bottom: 0;
|
|
width: 3px;
|
|
background: linear-gradient(180deg, #b8860b, #d4a017);
|
|
}
|
|
|
|
/* Hover states for desktop */
|
|
@media (hover: hover) {
|
|
.sidebar-item:hover {
|
|
background-color: rgba(68, 64, 60, 0.3);
|
|
}
|
|
.sidebar-section-item:hover {
|
|
color: #d4a017;
|
|
}
|
|
}
|
|
|
|
/* Section dividers */
|
|
.sidebar-section {
|
|
border-top: 1px solid #292524;
|
|
}
|
|
""")
|
|
|
|
|
|
def SidebarScript(): # noqa: N802
|
|
"""JavaScript for menu drawer open/close behavior."""
|
|
return Script("""
|
|
function openMenuDrawer() {
|
|
document.getElementById('menu-drawer').classList.add('open');
|
|
document.getElementById('menu-backdrop').classList.add('open');
|
|
document.body.style.overflow = 'hidden';
|
|
// Focus the drawer for keyboard events
|
|
document.getElementById('menu-drawer').focus();
|
|
}
|
|
|
|
function closeMenuDrawer() {
|
|
document.getElementById('menu-drawer').classList.remove('open');
|
|
document.getElementById('menu-backdrop').classList.remove('open');
|
|
document.body.style.overflow = '';
|
|
}
|
|
""")
|
|
|
|
|
|
def _primary_nav_item(label: str, href: str, icon_fn, is_active: bool):
|
|
"""Create a primary navigation item (Eggs, Feed, Move)."""
|
|
base_cls = "sidebar-item flex items-center gap-3 py-3 px-4 transition-colors duration-150 "
|
|
|
|
if is_active:
|
|
base_cls += "sidebar-nav-active bg-stone-800/50 text-amber-500"
|
|
else:
|
|
base_cls += "text-stone-400 hover:text-stone-200"
|
|
|
|
return A(
|
|
icon_fn(active=is_active),
|
|
Span(label, cls="font-medium"),
|
|
href=href,
|
|
cls=base_cls,
|
|
)
|
|
|
|
|
|
def _section_header(label: str):
|
|
"""Create a section header (ANIMALS, ADMIN, TOOLS)."""
|
|
return Div(
|
|
label,
|
|
cls="text-xs uppercase tracking-wider text-stone-500 px-4 py-2 mt-4 font-semibold",
|
|
)
|
|
|
|
|
|
def _section_item(label: str, href: str):
|
|
"""Create a section menu item."""
|
|
return A(
|
|
label,
|
|
href=href,
|
|
cls="sidebar-section-item block text-sm text-stone-400 py-2 px-4 pl-6 "
|
|
"transition-colors duration-150",
|
|
)
|
|
|
|
|
|
def _close_icon():
|
|
"""X icon for closing the drawer."""
|
|
return Svg(
|
|
Path(
|
|
d="M6 6L18 18M6 18L18 6",
|
|
stroke="currentColor",
|
|
stroke_width="2",
|
|
stroke_linecap="round",
|
|
),
|
|
viewBox="0 0 24 24",
|
|
width="24",
|
|
height="24",
|
|
cls="text-stone-400",
|
|
)
|
|
|
|
|
|
def NavMenuItems(user_role: UserRole | None = None): # noqa: N802
|
|
"""Shared menu items for sidebar and drawer.
|
|
|
|
Args:
|
|
user_role: User's role for admin section visibility.
|
|
|
|
Returns:
|
|
List of menu section components.
|
|
"""
|
|
items = []
|
|
|
|
# ANIMALS section
|
|
items.append(_section_header("Animals"))
|
|
items.append(_section_item("Registry", "/registry"))
|
|
items.append(_section_item("Create Cohort", "/actions/cohort"))
|
|
items.append(_section_item("Record Hatch", "/actions/hatch"))
|
|
items.append(_section_item("Add Tag", "/actions/tag-add"))
|
|
items.append(_section_item("End Tag", "/actions/tag-end"))
|
|
items.append(_section_item("Attributes", "/actions/attrs"))
|
|
items.append(_section_item("Outcome", "/actions/outcome"))
|
|
|
|
# ADMIN section (role-gated)
|
|
if user_role == UserRole.ADMIN:
|
|
items.append(Div(cls="sidebar-section mt-2"))
|
|
items.append(_section_header("Admin"))
|
|
items.append(_section_item("Locations", "/locations"))
|
|
items.append(_section_item("Status Correct", "/actions/status-correct"))
|
|
|
|
# TOOLS section
|
|
items.append(Div(cls="sidebar-section mt-2"))
|
|
items.append(_section_header("Tools"))
|
|
items.append(_section_item("Event Log", "/event-log"))
|
|
|
|
return items
|
|
|
|
|
|
def Sidebar( # noqa: N802
|
|
active_nav: str = "eggs",
|
|
user_role: UserRole | None = None,
|
|
username: str | None = None,
|
|
):
|
|
"""Desktop sidebar component (hidden on mobile).
|
|
|
|
Args:
|
|
active_nav: Currently active primary nav item ('eggs', 'feed', 'move').
|
|
user_role: User's role for admin section visibility.
|
|
username: Username to display in user badge.
|
|
|
|
Returns:
|
|
Sidebar component for desktop view.
|
|
"""
|
|
# User badge at bottom
|
|
role_label = user_role.value.title() if user_role else "Guest"
|
|
user_badge = Div(
|
|
Div(
|
|
Span(
|
|
username[0].upper() if username else "?",
|
|
cls="w-8 h-8 rounded-full bg-stone-700 flex items-center justify-center "
|
|
"text-amber-500 font-semibold text-sm",
|
|
),
|
|
Div(
|
|
Div(username or "Guest", cls="text-sm text-stone-300 font-medium"),
|
|
Div(role_label, cls="text-xs text-stone-500"),
|
|
cls="ml-3",
|
|
),
|
|
cls="flex items-center",
|
|
),
|
|
cls="border-t border-stone-800 p-4 mt-auto",
|
|
)
|
|
|
|
return Nav(
|
|
# Logo/Brand
|
|
Div(
|
|
Span("ANIMALTRACK", cls="text-amber-600 font-bold tracking-wider text-sm"),
|
|
cls="px-4 py-4 border-b border-stone-800",
|
|
),
|
|
# Primary navigation
|
|
Div(
|
|
_primary_nav_item("Eggs", "/", EggIcon, active_nav == "eggs"),
|
|
_primary_nav_item("Feed", "/feed", FeedIcon, active_nav == "feed"),
|
|
_primary_nav_item("Move", "/move", MoveIcon, active_nav == "move"),
|
|
cls="py-2",
|
|
),
|
|
# Menu sections
|
|
Div(
|
|
*NavMenuItems(user_role),
|
|
cls="flex-1 overflow-y-auto",
|
|
),
|
|
# User badge
|
|
user_badge,
|
|
id="desktop-sidebar",
|
|
cls="hidden md:flex md:flex-col md:w-60 md:fixed md:inset-y-0 "
|
|
"bg-[#141413] border-r border-stone-800 z-40",
|
|
)
|
|
|
|
|
|
def MenuDrawer(user_role: UserRole | None = None): # noqa: N802
|
|
"""Mobile menu drawer component (slides from right).
|
|
|
|
Args:
|
|
user_role: User's role for admin section visibility.
|
|
|
|
Returns:
|
|
Menu drawer component for mobile view.
|
|
"""
|
|
return Div(
|
|
# Backdrop
|
|
Div(
|
|
id="menu-backdrop",
|
|
cls="fixed inset-0 bg-black/60 z-40",
|
|
hx_on_click="closeMenuDrawer()",
|
|
),
|
|
# Drawer panel
|
|
Div(
|
|
# Header with close button
|
|
Div(
|
|
Span("MENU", cls="text-amber-600 font-bold tracking-wider text-sm"),
|
|
Button(
|
|
_close_icon(),
|
|
hx_on_click="closeMenuDrawer()",
|
|
cls="p-2 -mr-2 hover:bg-stone-800 rounded-lg transition-colors",
|
|
type="button",
|
|
),
|
|
cls="flex items-center justify-between px-4 py-4 border-b border-stone-800",
|
|
),
|
|
# Menu content
|
|
Div(
|
|
*NavMenuItems(user_role),
|
|
cls="overflow-y-auto flex-1 pb-8",
|
|
),
|
|
id="menu-drawer",
|
|
cls="fixed top-0 right-0 bottom-0 w-72 bg-[#141413] z-50 flex flex-col shadow-2xl",
|
|
tabindex="-1",
|
|
hx_on_keydown="if(event.key==='Escape') closeMenuDrawer()",
|
|
),
|
|
cls="md:hidden",
|
|
)
|