From 768a3e43522cd9a1ae0ab7987a591be1ebc91b60 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Wed, 31 Dec 2025 19:45:39 +0000 Subject: [PATCH] feat: redesign navigation with responsive sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Simplify bottom nav from 6 to 4 items (Eggs, Feed, Move, Menu) - Add persistent sidebar on desktop (hidden on mobile) - Add slide-out menu drawer on mobile - Rename Egg to Eggs with Harvest/Sell tabs (matching Feed pattern) - Redirect /sell to /?tab=sell for consistency - Role-gate Admin section (Locations, Status Correct) in sidebar - Add user badge to sidebar showing username and role 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/animaltrack/web/routes/actions.py | 12 +- src/animaltrack/web/routes/animals.py | 2 +- src/animaltrack/web/routes/eggs.py | 278 +++++++++++++++++++--- src/animaltrack/web/routes/products.py | 20 +- src/animaltrack/web/routes/registry.py | 2 +- src/animaltrack/web/templates/base.py | 42 +++- src/animaltrack/web/templates/eggs.py | 226 ++++++++++++++++-- src/animaltrack/web/templates/icons.py | 16 +- src/animaltrack/web/templates/nav.py | 46 ++-- src/animaltrack/web/templates/sidebar.py | 282 +++++++++++++++++++++++ 10 files changed, 824 insertions(+), 102 deletions(-) create mode 100644 src/animaltrack/web/templates/sidebar.py diff --git a/src/animaltrack/web/routes/actions.py b/src/animaltrack/web/routes/actions.py index ad1b848..3f22b73 100644 --- a/src/animaltrack/web/routes/actions.py +++ b/src/animaltrack/web/routes/actions.py @@ -89,7 +89,7 @@ def cohort_index(request: Request): return page( cohort_form(locations, species_list), title="Create Cohort - AnimalTrack", - active_nav="cohort", + active_nav=None, ) @@ -165,7 +165,7 @@ async def animal_cohort(request: Request): page( cohort_form(locations, species_list), title="Create Cohort - AnimalTrack", - active_nav="cohort", + active_nav=None, ) ), ) @@ -206,7 +206,7 @@ def _render_cohort_error( count_value=form_data.get("count", "") if form_data else "", ), title="Create Cohort - AnimalTrack", - active_nav="cohort", + active_nav=None, ) ), status_code=422, @@ -227,7 +227,7 @@ def hatch_index(request: Request): return page( hatch_form(locations, species_list), title="Record Hatch - AnimalTrack", - active_nav="hatch", + active_nav=None, ) @@ -299,7 +299,7 @@ async def hatch_recorded(request: Request): page( hatch_form(locations, species_list), title="Record Hatch - AnimalTrack", - active_nav="hatch", + active_nav=None, ) ), ) @@ -340,7 +340,7 @@ def _render_hatch_error( hatched_live_value=form_data.get("hatched_live", "") if form_data else "", ), title="Record Hatch - AnimalTrack", - active_nav="hatch", + active_nav=None, ) ), status_code=422, diff --git a/src/animaltrack/web/routes/animals.py b/src/animaltrack/web/routes/animals.py index 6349932..c8bd45d 100644 --- a/src/animaltrack/web/routes/animals.py +++ b/src/animaltrack/web/routes/animals.py @@ -38,7 +38,7 @@ def animal_detail(request: Request, animal_id: str): return page( animal_detail_page(animal, timeline, merge_info), title=title, - active_nav="registry", + active_nav=None, ) diff --git a/src/animaltrack/web/routes/eggs.py b/src/animaltrack/web/routes/eggs.py index 0bf7ce2..d0cac91 100644 --- a/src/animaltrack/web/routes/eggs.py +++ b/src/animaltrack/web/routes/eggs.py @@ -11,7 +11,7 @@ from fasthtml.common import to_xml from starlette.requests import Request from starlette.responses import HTMLResponse -from animaltrack.events.payloads import ProductCollectedPayload +from animaltrack.events.payloads import ProductCollectedPayload, ProductSoldPayload from animaltrack.events.store import EventStore from animaltrack.models.reference import UserDefault from animaltrack.projections import EventLogProjection, ProjectionRegistry @@ -20,11 +20,12 @@ from animaltrack.projections.event_animals import EventAnimalsProjection from animaltrack.projections.intervals import IntervalProjection from animaltrack.projections.products import ProductsProjection from animaltrack.repositories.locations import LocationRepository +from animaltrack.repositories.products import ProductRepository from animaltrack.repositories.user_defaults import UserDefaultsRepository from animaltrack.repositories.users import UserRepository from animaltrack.services.products import ProductService, ValidationError from animaltrack.web.templates import page -from animaltrack.web.templates.eggs import egg_form +from animaltrack.web.templates.eggs import eggs_page def resolve_ducks_at_location(db: Any, location_id: str, ts_utc: int) -> list[str]: @@ -53,27 +54,58 @@ def resolve_ducks_at_location(db: Any, location_id: str, ts_utc: int) -> list[st return [row[0] for row in rows] +def _get_sellable_products(db): + """Get list of active, sellable products. + + Args: + db: Database connection. + + Returns: + List of sellable Product objects. + """ + repo = ProductRepository(db) + all_products = repo.list_all() + return [p for p in all_products if p.active and p.sellable] + + def egg_index(request: Request): - """GET / - Egg Quick Capture form.""" + """GET / - Eggs page with Harvest/Sell tabs.""" db = request.app.state.db locations = LocationRepository(db).list_active() + products = _get_sellable_products(db) + + # Get auth info for user role + auth = request.scope.get("auth") + username = auth.username if auth else None + user_role = auth.role if auth else None + + # Check for active tab from query params + active_tab = request.query_params.get("tab", "harvest") + if active_tab not in ("harvest", "sell"): + active_tab = "harvest" # Check for pre-selected location from query params selected_location_id = request.query_params.get("location_id") # If no query param, load from user defaults - if not selected_location_id: - auth = request.scope.get("auth") - username = auth.username if auth else None - if username: - defaults = UserDefaultsRepository(db).get(username, "collect_egg") - if defaults: - selected_location_id = defaults.location_id + if not selected_location_id and username: + defaults = UserDefaultsRepository(db).get(username, "collect_egg") + if defaults: + selected_location_id = defaults.location_id return page( - egg_form(locations, selected_location_id=selected_location_id, action=product_collected), - title="Egg - AnimalTrack", - active_nav="egg", + eggs_page( + locations, + products, + active_tab=active_tab, + selected_location_id=selected_location_id, + harvest_action=product_collected, + sell_action=product_sold, + ), + title="Eggs - AnimalTrack", + active_nav="eggs", + user_role=user_role, + username=username, ) @@ -82,27 +114,37 @@ async def product_collected(request: Request): db = request.app.state.db form = await request.form() + # Get auth info + auth = request.scope.get("auth") + actor = auth.username if auth else "unknown" + user_role = auth.role if auth else None + # Extract form data location_id = form.get("location_id", "") quantity_str = form.get("quantity", "0") notes = form.get("notes") or None nonce = form.get("nonce") - # Get locations for potential re-render + # Get data for potential re-render locations = LocationRepository(db).list_active() + products = _get_sellable_products(db) # Validate location_id if not location_id: - return _render_error_form(locations, None, "Please select a location") + return _render_harvest_error(request, locations, products, None, "Please select a location") # Validate quantity try: quantity = int(quantity_str) except ValueError: - return _render_error_form(locations, location_id, "Quantity must be a number") + return _render_harvest_error( + request, locations, products, location_id, "Quantity must be a number" + ) if quantity < 1: - return _render_error_form(locations, location_id, "Quantity must be at least 1") + return _render_harvest_error( + request, locations, products, location_id, "Quantity must be at least 1" + ) # Get current timestamp ts_utc = int(time.time() * 1000) @@ -111,7 +153,9 @@ async def product_collected(request: Request): resolved_ids = resolve_ducks_at_location(db, location_id, ts_utc) if not resolved_ids: - return _render_error_form(locations, location_id, "No ducks at this location") + return _render_harvest_error( + request, locations, products, location_id, "No ducks at this location" + ) # Create product service event_store = EventStore(db) @@ -133,10 +177,6 @@ async def product_collected(request: Request): notes=notes, ) - # Get actor from auth - auth = request.scope.get("auth") - actor = auth.username if auth else "unknown" - # Collect product try: product_service.collect_product( @@ -147,7 +187,7 @@ async def product_collected(request: Request): route="/actions/product-collected", ) except ValidationError as e: - return _render_error_form(locations, location_id, str(e)) + return _render_harvest_error(request, locations, products, location_id, str(e)) # Save user defaults (only if user exists in database) if UserRepository(db).get(actor): @@ -164,9 +204,18 @@ async def product_collected(request: Request): response = HTMLResponse( content=to_xml( page( - egg_form(locations, selected_location_id=location_id, action=product_collected), - title="Egg - AnimalTrack", - active_nav="egg", + eggs_page( + locations, + products, + active_tab="harvest", + selected_location_id=location_id, + harvest_action=product_collected, + sell_action=product_sold, + ), + title="Eggs - AnimalTrack", + active_nav="eggs", + user_role=user_role, + username=actor, ) ), ) @@ -179,6 +228,118 @@ async def product_collected(request: Request): return response +async def product_sold(request: Request): + """POST /actions/product-sold - Record product sale (from Eggs page Sell tab).""" + db = request.app.state.db + form = await request.form() + + # Get auth info + auth = request.scope.get("auth") + actor = auth.username if auth else "unknown" + user_role = auth.role if auth else None + + # Extract form data + product_code = form.get("product_code", "") + quantity_str = form.get("quantity", "0") + total_price_str = form.get("total_price_cents", "0") + buyer = form.get("buyer") or None + notes = form.get("notes") or None + nonce = form.get("nonce") + + # Get data for potential re-render + locations = LocationRepository(db).list_active() + products = _get_sellable_products(db) + + # Validate product_code + if not product_code: + return _render_sell_error(request, locations, products, None, "Please select a product") + + # Validate quantity + try: + quantity = int(quantity_str) + except ValueError: + return _render_sell_error( + request, locations, products, product_code, "Quantity must be a number" + ) + + if quantity < 1: + return _render_sell_error( + request, locations, products, product_code, "Quantity must be at least 1" + ) + + # Validate total_price_cents + try: + total_price_cents = int(total_price_str) + except ValueError: + return _render_sell_error( + request, locations, products, product_code, "Total price must be a number" + ) + + if total_price_cents < 0: + return _render_sell_error( + request, locations, products, product_code, "Total price cannot be negative" + ) + + # Get current timestamp + ts_utc = int(time.time() * 1000) + + # Create product service + event_store = EventStore(db) + registry = ProjectionRegistry() + registry.register(ProductsProjection(db)) + registry.register(EventLogProjection(db)) + + product_service = ProductService(db, event_store, registry) + + # Create payload + payload = ProductSoldPayload( + product_code=product_code, + quantity=quantity, + total_price_cents=total_price_cents, + buyer=buyer, + notes=notes, + ) + + # Sell product + try: + product_service.sell_product( + payload=payload, + ts_utc=ts_utc, + actor=actor, + nonce=nonce, + route="/actions/product-sold", + ) + except ValidationError as e: + return _render_sell_error(request, locations, products, product_code, str(e)) + + # Success: re-render form with product sticking + response = HTMLResponse( + content=to_xml( + page( + eggs_page( + locations, + products, + active_tab="sell", + selected_product_code=product_code, + harvest_action=product_collected, + sell_action=product_sold, + ), + title="Eggs - AnimalTrack", + active_nav="eggs", + user_role=user_role, + username=actor, + ) + ), + ) + + # Add toast trigger header + response.headers["HX-Trigger"] = json.dumps( + {"showToast": {"message": f"Recorded sale of {quantity} {product_code}", "type": "success"}} + ) + + return response + + def register_egg_routes(rt, app): """Register egg capture routes. @@ -188,30 +349,81 @@ def register_egg_routes(rt, app): """ rt("/")(egg_index) rt("/actions/product-collected", methods=["POST"])(product_collected) + rt("/actions/product-sold", methods=["POST"])(product_sold) -def _render_error_form(locations, selected_location_id, error_message): - """Render form with error message. +def _render_harvest_error(request, locations, products, selected_location_id, error_message): + """Render harvest form with error message. Args: + request: The HTTP request. locations: List of active locations. + products: List of sellable products. selected_location_id: Currently selected location. error_message: Error message to display. Returns: HTMLResponse with 422 status. """ + auth = request.scope.get("auth") + user_role = auth.role if auth else None + username = auth.username if auth else None + return HTMLResponse( content=to_xml( page( - egg_form( + eggs_page( locations, + products, + active_tab="harvest", selected_location_id=selected_location_id, - error=error_message, - action=product_collected, + harvest_error=error_message, + harvest_action=product_collected, + sell_action=product_sold, ), - title="Egg - AnimalTrack", - active_nav="egg", + title="Eggs - AnimalTrack", + active_nav="eggs", + user_role=user_role, + username=username, + ) + ), + status_code=422, + ) + + +def _render_sell_error(request, locations, products, selected_product_code, error_message): + """Render sell form with error message. + + Args: + request: The HTTP request. + locations: List of active locations. + products: List of sellable products. + selected_product_code: Currently selected product code. + error_message: Error message to display. + + Returns: + HTMLResponse with 422 status. + """ + auth = request.scope.get("auth") + user_role = auth.role if auth else None + username = auth.username if auth else None + + return HTMLResponse( + content=to_xml( + page( + eggs_page( + locations, + products, + active_tab="sell", + selected_product_code=selected_product_code, + sell_error=error_message, + harvest_action=product_collected, + sell_action=product_sold, + ), + title="Eggs - AnimalTrack", + active_nav="eggs", + user_role=user_role, + username=username, ) ), status_code=422, diff --git a/src/animaltrack/web/routes/products.py b/src/animaltrack/web/routes/products.py index 4bba0fe..c2b6941 100644 --- a/src/animaltrack/web/routes/products.py +++ b/src/animaltrack/web/routes/products.py @@ -35,20 +35,16 @@ def _get_sellable_products(db): def sell_index(request: Request): - """GET /sell - Product Sold form.""" - db = request.app.state.db - products = _get_sellable_products(db) + """GET /sell - Redirect to Eggs page Sell tab.""" + from starlette.responses import RedirectResponse - # Check for pre-selected product from query params (defaults to egg.duck) - selected_product_code = request.query_params.get("product_code", "egg.duck") + # Preserve product_code if provided + product_code = request.query_params.get("product_code") + redirect_url = "/?tab=sell" + if product_code: + redirect_url = f"/?tab=sell&product_code={product_code}" - return page( - product_sold_form( - products, selected_product_code=selected_product_code, action=product_sold - ), - title="Sell - AnimalTrack", - active_nav=None, - ) + return RedirectResponse(url=redirect_url, status_code=302) async def product_sold(request: Request): diff --git a/src/animaltrack/web/routes/registry.py b/src/animaltrack/web/routes/registry.py index 56726f9..4b1d568 100644 --- a/src/animaltrack/web/routes/registry.py +++ b/src/animaltrack/web/routes/registry.py @@ -62,7 +62,7 @@ def registry_index(request: Request): species_list=species_list, ), title="Registry - AnimalTrack", - active_nav="registry", + active_nav=None, ) diff --git a/src/animaltrack/web/templates/base.py b/src/animaltrack/web/templates/base.py index ab461f0..0b4e4e8 100644 --- a/src/animaltrack/web/templates/base.py +++ b/src/animaltrack/web/templates/base.py @@ -1,9 +1,16 @@ # ABOUTME: Base HTML template for AnimalTrack pages. -# ABOUTME: Provides consistent layout with MonsterUI theme and bottom nav. +# ABOUTME: Provides consistent layout with MonsterUI theme and responsive nav. from fasthtml.common import Container, Div, Script, Title +from animaltrack.models.reference import UserRole from animaltrack.web.templates.nav import BottomNav, BottomNavStyles +from animaltrack.web.templates.sidebar import ( + MenuDrawer, + Sidebar, + SidebarScript, + SidebarStyles, +) def toast_container(): @@ -65,21 +72,29 @@ def toast_script(): return Script(script) -def page(content, title: str = "AnimalTrack", active_nav: str = "egg"): +def page( + content, + title: str = "AnimalTrack", + active_nav: str = "eggs", + user_role: UserRole | None = None, + username: str | None = None, +): """ - Base page template wrapper with navigation. + Base page template wrapper with responsive navigation. Wraps content with consistent HTML structure including: - Page title - - Bottom navigation styling - - Content container with nav padding - - Fixed bottom navigation bar + - Desktop sidebar (hidden on mobile) + - Mobile bottom nav (hidden on desktop) + - Mobile menu drawer - Toast container for notifications Args: content: Page content (FT components) title: Page title for browser tab - active_nav: Active nav item ID ('egg', 'feed', 'move', 'registry') + active_nav: Active nav item ID ('eggs', 'feed', 'move') + user_role: User's role for admin section visibility in menu + username: Username to display in sidebar user badge Returns: Tuple of FT components for the complete page @@ -87,15 +102,24 @@ def page(content, title: str = "AnimalTrack", active_nav: str = "egg"): return ( Title(title), BottomNavStyles(), + SidebarStyles(), toast_script(), - # Main content with bottom padding for fixed nav + SidebarScript(), + # Desktop sidebar + Sidebar(active_nav=active_nav, user_role=user_role, username=username), + # Mobile menu drawer + MenuDrawer(user_role=user_role), + # Main content with responsive padding/margin + # pb-20 for mobile bottom nav, md:pb-4 for desktop (no bottom nav) + # md:ml-60 to offset for desktop sidebar # hx-boost enables AJAX for all descendant forms/links Div( Container(content), hx_boost="true", hx_target="body", - cls="pb-20 min-h-screen bg-[#0f0f0e] text-stone-100", + cls="pb-20 md:pb-4 md:ml-60 min-h-screen bg-[#0f0f0e] text-stone-100", ), toast_container(), + # Mobile bottom nav BottomNav(active_id=active_nav), ) diff --git a/src/animaltrack/web/templates/eggs.py b/src/animaltrack/web/templates/eggs.py index 558f3a0..a9bf204 100644 --- a/src/animaltrack/web/templates/eggs.py +++ b/src/animaltrack/web/templates/eggs.py @@ -1,23 +1,105 @@ -# ABOUTME: Templates for Egg Quick Capture form. -# ABOUTME: Provides form components for recording egg collections. +# ABOUTME: Templates for Egg Quick Capture forms. +# ABOUTME: Provides form components for recording egg harvests and sales. from collections.abc import Callable from typing import Any -from fasthtml.common import H2, A, Div, Form, Hidden, Option, P -from monsterui.all import Button, ButtonT, LabelInput, LabelSelect, LabelTextArea +from fasthtml.common import H1, H2, A, Div, Form, Hidden, Li, Option, P, Ul +from monsterui.all import ( + Button, + ButtonT, + LabelInput, + LabelSelect, + LabelTextArea, + TabContainer, +) from ulid import ULID -from animaltrack.models.reference import Location +from animaltrack.models.reference import Location, Product -def egg_form( +def eggs_page( + locations: list[Location], + products: list[Product], + active_tab: str = "harvest", + selected_location_id: str | None = None, + selected_product_code: str | None = "egg.duck", + harvest_error: str | None = None, + sell_error: str | None = None, + harvest_action: Callable[..., Any] | str = "/actions/product-collected", + sell_action: Callable[..., Any] | str = "/actions/product-sold", +): + """Create the Eggs page with tabbed forms. + + Args: + locations: List of active locations for the harvest dropdown. + products: List of sellable products for the sell dropdown. + active_tab: Which tab is active ('harvest' or 'sell'). + selected_location_id: Pre-selected location ID (sticks after harvest). + selected_product_code: Pre-selected product code for sell form. + harvest_error: Error message for harvest form. + sell_error: Error message for sell form. + harvest_action: Route function or URL for harvest form. + sell_action: Route function or URL for sell form. + + Returns: + Page content with tabbed forms. + """ + harvest_active = active_tab == "harvest" + + return Div( + H1("Eggs", cls="text-2xl font-bold mb-6"), + # Tab navigation + TabContainer( + Li( + A( + "Harvest", + href="#", + cls="uk-active" if harvest_active else "", + ), + ), + Li( + A( + "Sell", + href="#", + cls="" if harvest_active else "uk-active", + ), + ), + uk_switcher="connect: #egg-forms; animation: uk-animation-fade", + alt=True, + ), + # Tab content + Ul(id="egg-forms", cls="uk-switcher mt-4")( + Li( + harvest_form( + locations, + selected_location_id=selected_location_id, + error=harvest_error, + action=harvest_action, + ), + cls="uk-active" if harvest_active else "", + ), + Li( + sell_form( + products, + selected_product_code=selected_product_code, + error=sell_error, + action=sell_action, + ), + cls="" if harvest_active else "uk-active", + ), + ), + cls="p-4", + ) + + +def harvest_form( locations: list[Location], selected_location_id: str | None = None, error: str | None = None, action: Callable[..., Any] | str = "/actions/product-collected", -) -> Div: - """Create the Egg Quick Capture form with Record Sale link. +) -> Form: + """Create the Harvest form for egg collection. Args: locations: List of active locations for the dropdown. @@ -26,7 +108,7 @@ def egg_form( action: Route function or URL string for form submission. Returns: - Div containing the form and a link to Record Sale page. + Form component for recording egg harvests. """ # Build location options location_options = [ @@ -52,8 +134,8 @@ def egg_form( cls="mb-4", ) - form = Form( - H2("Record Eggs", cls="text-xl font-bold mb-4"), + return Form( + H2("Harvest Eggs", cls="text-xl font-bold mb-4"), # Error message if present error_component, # Location dropdown @@ -83,21 +165,123 @@ def egg_form( # Hidden nonce for idempotency Hidden(name="nonce", value=str(ULID())), # Submit button - Button("Record Eggs", type="submit", cls=ButtonT.primary), + Button("Record Harvest", type="submit", cls=ButtonT.primary), # Form submission via standard action/method (hx-boost handles AJAX) action=action, method="post", cls="space-y-4", ) - return Div( - form, - Div( - A( - "Record Sale", - href="/sell", - cls="text-sm text-blue-400 hover:text-blue-300 underline", - ), - cls="mt-4 text-center", + +def sell_form( + products: list[Product], + selected_product_code: str | None = "egg.duck", + error: str | None = None, + action: Callable[..., Any] | str = "/actions/product-sold", +) -> Form: + """Create the Sell form for recording sales. + + Args: + products: List of sellable products for the dropdown. + selected_product_code: Pre-selected product code (defaults to egg.duck). + error: Optional error message to display. + action: Route function or URL string for form submission. + + Returns: + Form component for recording product sales. + """ + # Build product options + product_options = [ + Option( + f"{product.name} ({product.code})", + value=product.code, + selected=(product.code == selected_product_code), + ) + for product in products + ] + + # Add placeholder option if no product is selected + if selected_product_code is None: + product_options.insert( + 0, Option("Select a product...", value="", disabled=True, selected=True) + ) + + # Error display component + error_component = None + if error: + error_component = Div( + P(error, cls="text-red-500 text-sm"), + cls="mb-4", + ) + + return Form( + H2("Sell Products", cls="text-xl font-bold mb-4"), + # Error message if present + error_component, + # Product dropdown + LabelSelect( + *product_options, + label="Product", + id="product_code", + name="product_code", ), + # Quantity input (integer only, min=1) + LabelInput( + "Quantity", + id="quantity", + name="quantity", + type="number", + min="1", + step="1", + placeholder="Number of items sold", + required=True, + ), + # Total price in cents + LabelInput( + "Total Price (cents)", + id="total_price_cents", + name="total_price_cents", + type="number", + min="0", + step="1", + placeholder="Total price in cents", + required=True, + ), + # Optional buyer + LabelInput( + "Buyer", + id="buyer", + name="buyer", + type="text", + placeholder="Optional buyer name", + ), + # Optional notes + LabelTextArea( + "Notes", + id="sell_notes", + name="notes", + placeholder="Optional notes", + ), + # Hidden nonce for idempotency + Hidden(name="nonce", value=str(ULID())), + # Submit button + Button("Record Sale", type="submit", cls=ButtonT.primary), + # Form submission via standard action/method (hx-boost handles AJAX) + action=action, + method="post", + cls="space-y-4", ) + + +# Keep the old function name for backwards compatibility +def egg_form( + locations: list[Location], + selected_location_id: str | None = None, + error: str | None = None, + action: Callable[..., Any] | str = "/actions/product-collected", +) -> Div: + """Legacy function - returns harvest form wrapped in a Div. + + Deprecated: Use eggs_page() for the full tabbed interface. + """ + return Div(harvest_form(locations, selected_location_id, error, action)) diff --git a/src/animaltrack/web/templates/icons.py b/src/animaltrack/web/templates/icons.py index d7b854c..84aaf0a 100644 --- a/src/animaltrack/web/templates/icons.py +++ b/src/animaltrack/web/templates/icons.py @@ -142,12 +142,26 @@ def HatchIcon(active: bool = False): # noqa: N802 ) +def MenuIcon(active: bool = False): # noqa: N802 + """Menu/hamburger icon - three horizontal lines.""" + fill = "#b8860b" if active else "#6b6b63" + return Svg( + Path(d="M4 6H20", stroke=fill, stroke_width="2.5", stroke_linecap="round"), + Path(d="M4 12H20", stroke=fill, stroke_width="2.5", stroke_linecap="round"), + Path(d="M4 18H20", stroke=fill, stroke_width="2.5", stroke_linecap="round"), + viewBox="0 0 24 24", + width="28", + height="28", + ) + + # Icon mapping by nav item ID NAV_ICONS = { - "egg": EggIcon, + "eggs": EggIcon, "feed": FeedIcon, "move": MoveIcon, "registry": RegistryIcon, "cohort": CohortIcon, "hatch": HatchIcon, + "menu": MenuIcon, } diff --git a/src/animaltrack/web/templates/nav.py b/src/animaltrack/web/templates/nav.py index 7f82928..bfab87a 100644 --- a/src/animaltrack/web/templates/nav.py +++ b/src/animaltrack/web/templates/nav.py @@ -1,18 +1,16 @@ # ABOUTME: Bottom navigation component for AnimalTrack mobile UI. # ABOUTME: Industrial farm aesthetic with large touch targets and high contrast. -from fasthtml.common import A, Div, Span, Style +from fasthtml.common import A, Button, Div, Span, Style from animaltrack.web.templates.icons import NAV_ICONS -# Navigation items configuration +# Navigation items configuration (simplified to 4 items) NAV_ITEMS = [ - {"id": "egg", "label": "Egg", "href": "/"}, + {"id": "eggs", "label": "Eggs", "href": "/"}, {"id": "feed", "label": "Feed", "href": "/feed"}, {"id": "move", "label": "Move", "href": "/move"}, - {"id": "cohort", "label": "Cohort", "href": "/actions/cohort"}, - {"id": "hatch", "label": "Hatch", "href": "/actions/hatch"}, - {"id": "registry", "label": "Registry", "href": "/registry"}, + {"id": "menu", "label": "Menu", "href": None}, # Opens drawer, no href ] @@ -61,12 +59,12 @@ def BottomNavStyles(): # noqa: N802 """) -def BottomNav(active_id: str = "egg"): # noqa: N802 +def BottomNav(active_id: str = "eggs"): # noqa: N802 """ - Fixed bottom navigation bar for AnimalTrack. + Fixed bottom navigation bar for AnimalTrack (mobile only). Args: - active_id: Currently active nav item ('egg', 'feed', 'move', 'registry') + active_id: Currently active nav item ('eggs', 'feed', 'move') Returns: FT component for the bottom nav @@ -84,21 +82,33 @@ def BottomNav(active_id: str = "egg"): # noqa: N802 if is_active: item_cls += "bg-stone-900/50 rounded-lg" - link_cls = ( + wrapper_cls = ( "relative flex-1 flex items-center justify-center min-h-[64px] " "transition-all duration-150 active:scale-95 " ) if is_active: - link_cls += "nav-item-active" + wrapper_cls += "nav-item-active" + inner = Div( + icon_fn(active=is_active), + Span(item["label"], cls=label_cls), + cls=item_cls, + ) + + # Menu item is a button that opens the drawer + if item["id"] == "menu": + return Button( + inner, + onclick="openMenuDrawer()", + cls=wrapper_cls, + type="button", + ) + + # Regular nav items are links return A( - Div( - icon_fn(active=is_active), - Span(item["label"], cls=label_cls), - cls=item_cls, - ), + inner, href=item["href"], - cls=link_cls, + cls=wrapper_cls, ) return Div( @@ -109,6 +119,6 @@ def BottomNav(active_id: str = "egg"): # noqa: N802 *[nav_item(item) for item in NAV_ITEMS], cls="flex items-stretch bg-[#1a1a18] safe-area-pb", ), - cls="fixed bottom-0 left-0 right-0 z-50", + cls="fixed bottom-0 left-0 right-0 z-50 md:hidden", id="bottom-nav", ) diff --git a/src/animaltrack/web/templates/sidebar.py b/src/animaltrack/web/templates/sidebar.py new file mode 100644 index 0000000..929aeb1 --- /dev/null +++ b/src/animaltrack/web/templates/sidebar.py @@ -0,0 +1,282 @@ +# 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'; + } + + function closeMenuDrawer() { + document.getElementById('menu-drawer').classList.remove('open'); + document.getElementById('menu-backdrop').classList.remove('open'); + document.body.style.overflow = ''; + } + + // Close on escape key + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') { + closeMenuDrawer(); + } + }); + """) + + +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", + onclick="closeMenuDrawer()", + ), + # Drawer panel + Div( + # Header with close button + Div( + Span("MENU", cls="text-amber-600 font-bold tracking-wider text-sm"), + Button( + _close_icon(), + onclick="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", + ), + cls="md:hidden", + )