Files
animaltrack/src/animaltrack/web/templates/sidebar.py
Petru Paler 3937d675ba feat: add event detail slide-over, fix toasts, and checkbox selection
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>
2026-01-01 19:10:57 +00:00

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",
)