feat: redesign navigation with responsive sidebar

- 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 <noreply@anthropic.com>
This commit is contained in:
2025-12-31 19:45:39 +00:00
parent c6a87e35d4
commit 768a3e4352
10 changed files with 824 additions and 102 deletions

View File

@@ -89,7 +89,7 @@ def cohort_index(request: Request):
return page( return page(
cohort_form(locations, species_list), cohort_form(locations, species_list),
title="Create Cohort - AnimalTrack", title="Create Cohort - AnimalTrack",
active_nav="cohort", active_nav=None,
) )
@@ -165,7 +165,7 @@ async def animal_cohort(request: Request):
page( page(
cohort_form(locations, species_list), cohort_form(locations, species_list),
title="Create Cohort - AnimalTrack", 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 "", count_value=form_data.get("count", "") if form_data else "",
), ),
title="Create Cohort - AnimalTrack", title="Create Cohort - AnimalTrack",
active_nav="cohort", active_nav=None,
) )
), ),
status_code=422, status_code=422,
@@ -227,7 +227,7 @@ def hatch_index(request: Request):
return page( return page(
hatch_form(locations, species_list), hatch_form(locations, species_list),
title="Record Hatch - AnimalTrack", title="Record Hatch - AnimalTrack",
active_nav="hatch", active_nav=None,
) )
@@ -299,7 +299,7 @@ async def hatch_recorded(request: Request):
page( page(
hatch_form(locations, species_list), hatch_form(locations, species_list),
title="Record Hatch - AnimalTrack", 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 "", hatched_live_value=form_data.get("hatched_live", "") if form_data else "",
), ),
title="Record Hatch - AnimalTrack", title="Record Hatch - AnimalTrack",
active_nav="hatch", active_nav=None,
) )
), ),
status_code=422, status_code=422,

View File

@@ -38,7 +38,7 @@ def animal_detail(request: Request, animal_id: str):
return page( return page(
animal_detail_page(animal, timeline, merge_info), animal_detail_page(animal, timeline, merge_info),
title=title, title=title,
active_nav="registry", active_nav=None,
) )

View File

@@ -11,7 +11,7 @@ from fasthtml.common import to_xml
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import HTMLResponse 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.events.store import EventStore
from animaltrack.models.reference import UserDefault from animaltrack.models.reference import UserDefault
from animaltrack.projections import EventLogProjection, ProjectionRegistry 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.intervals import IntervalProjection
from animaltrack.projections.products import ProductsProjection from animaltrack.projections.products import ProductsProjection
from animaltrack.repositories.locations import LocationRepository from animaltrack.repositories.locations import LocationRepository
from animaltrack.repositories.products import ProductRepository
from animaltrack.repositories.user_defaults import UserDefaultsRepository from animaltrack.repositories.user_defaults import UserDefaultsRepository
from animaltrack.repositories.users import UserRepository from animaltrack.repositories.users import UserRepository
from animaltrack.services.products import ProductService, ValidationError from animaltrack.services.products import ProductService, ValidationError
from animaltrack.web.templates import page 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]: 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] 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): def egg_index(request: Request):
"""GET / - Egg Quick Capture form.""" """GET / - Eggs page with Harvest/Sell tabs."""
db = request.app.state.db db = request.app.state.db
locations = LocationRepository(db).list_active() 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 # Check for pre-selected location from query params
selected_location_id = request.query_params.get("location_id") selected_location_id = request.query_params.get("location_id")
# If no query param, load from user defaults # If no query param, load from user defaults
if not selected_location_id: if not selected_location_id and username:
auth = request.scope.get("auth") defaults = UserDefaultsRepository(db).get(username, "collect_egg")
username = auth.username if auth else None if defaults:
if username: selected_location_id = defaults.location_id
defaults = UserDefaultsRepository(db).get(username, "collect_egg")
if defaults:
selected_location_id = defaults.location_id
return page( return page(
egg_form(locations, selected_location_id=selected_location_id, action=product_collected), eggs_page(
title="Egg - AnimalTrack", locations,
active_nav="egg", 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 db = request.app.state.db
form = await request.form() 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 # Extract form data
location_id = form.get("location_id", "") location_id = form.get("location_id", "")
quantity_str = form.get("quantity", "0") quantity_str = form.get("quantity", "0")
notes = form.get("notes") or None notes = form.get("notes") or None
nonce = form.get("nonce") nonce = form.get("nonce")
# Get locations for potential re-render # Get data for potential re-render
locations = LocationRepository(db).list_active() locations = LocationRepository(db).list_active()
products = _get_sellable_products(db)
# Validate location_id # Validate location_id
if not 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 # Validate quantity
try: try:
quantity = int(quantity_str) quantity = int(quantity_str)
except ValueError: 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: 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 # Get current timestamp
ts_utc = int(time.time() * 1000) 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) resolved_ids = resolve_ducks_at_location(db, location_id, ts_utc)
if not resolved_ids: 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 # Create product service
event_store = EventStore(db) event_store = EventStore(db)
@@ -133,10 +177,6 @@ async def product_collected(request: Request):
notes=notes, notes=notes,
) )
# Get actor from auth
auth = request.scope.get("auth")
actor = auth.username if auth else "unknown"
# Collect product # Collect product
try: try:
product_service.collect_product( product_service.collect_product(
@@ -147,7 +187,7 @@ async def product_collected(request: Request):
route="/actions/product-collected", route="/actions/product-collected",
) )
except ValidationError as e: 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) # Save user defaults (only if user exists in database)
if UserRepository(db).get(actor): if UserRepository(db).get(actor):
@@ -164,9 +204,18 @@ async def product_collected(request: Request):
response = HTMLResponse( response = HTMLResponse(
content=to_xml( content=to_xml(
page( page(
egg_form(locations, selected_location_id=location_id, action=product_collected), eggs_page(
title="Egg - AnimalTrack", locations,
active_nav="egg", 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 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): def register_egg_routes(rt, app):
"""Register egg capture routes. """Register egg capture routes.
@@ -188,30 +349,81 @@ def register_egg_routes(rt, app):
""" """
rt("/")(egg_index) rt("/")(egg_index)
rt("/actions/product-collected", methods=["POST"])(product_collected) 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): def _render_harvest_error(request, locations, products, selected_location_id, error_message):
"""Render form with error message. """Render harvest form with error message.
Args: Args:
request: The HTTP request.
locations: List of active locations. locations: List of active locations.
products: List of sellable products.
selected_location_id: Currently selected location. selected_location_id: Currently selected location.
error_message: Error message to display. error_message: Error message to display.
Returns: Returns:
HTMLResponse with 422 status. 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( return HTMLResponse(
content=to_xml( content=to_xml(
page( page(
egg_form( eggs_page(
locations, locations,
products,
active_tab="harvest",
selected_location_id=selected_location_id, selected_location_id=selected_location_id,
error=error_message, harvest_error=error_message,
action=product_collected, harvest_action=product_collected,
sell_action=product_sold,
), ),
title="Egg - AnimalTrack", title="Eggs - AnimalTrack",
active_nav="egg", 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, status_code=422,

View File

@@ -35,20 +35,16 @@ def _get_sellable_products(db):
def sell_index(request: Request): def sell_index(request: Request):
"""GET /sell - Product Sold form.""" """GET /sell - Redirect to Eggs page Sell tab."""
db = request.app.state.db from starlette.responses import RedirectResponse
products = _get_sellable_products(db)
# Check for pre-selected product from query params (defaults to egg.duck) # Preserve product_code if provided
selected_product_code = request.query_params.get("product_code", "egg.duck") 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( return RedirectResponse(url=redirect_url, status_code=302)
product_sold_form(
products, selected_product_code=selected_product_code, action=product_sold
),
title="Sell - AnimalTrack",
active_nav=None,
)
async def product_sold(request: Request): async def product_sold(request: Request):

View File

@@ -62,7 +62,7 @@ def registry_index(request: Request):
species_list=species_list, species_list=species_list,
), ),
title="Registry - AnimalTrack", title="Registry - AnimalTrack",
active_nav="registry", active_nav=None,
) )

View File

@@ -1,9 +1,16 @@
# ABOUTME: Base HTML template for AnimalTrack pages. # 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 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.nav import BottomNav, BottomNavStyles
from animaltrack.web.templates.sidebar import (
MenuDrawer,
Sidebar,
SidebarScript,
SidebarStyles,
)
def toast_container(): def toast_container():
@@ -65,21 +72,29 @@ def toast_script():
return Script(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: Wraps content with consistent HTML structure including:
- Page title - Page title
- Bottom navigation styling - Desktop sidebar (hidden on mobile)
- Content container with nav padding - Mobile bottom nav (hidden on desktop)
- Fixed bottom navigation bar - Mobile menu drawer
- Toast container for notifications - Toast container for notifications
Args: Args:
content: Page content (FT components) content: Page content (FT components)
title: Page title for browser tab 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: Returns:
Tuple of FT components for the complete page Tuple of FT components for the complete page
@@ -87,15 +102,24 @@ def page(content, title: str = "AnimalTrack", active_nav: str = "egg"):
return ( return (
Title(title), Title(title),
BottomNavStyles(), BottomNavStyles(),
SidebarStyles(),
toast_script(), 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 # hx-boost enables AJAX for all descendant forms/links
Div( Div(
Container(content), Container(content),
hx_boost="true", hx_boost="true",
hx_target="body", 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(), toast_container(),
# Mobile bottom nav
BottomNav(active_id=active_nav), BottomNav(active_id=active_nav),
) )

View File

@@ -1,23 +1,105 @@
# ABOUTME: Templates for Egg Quick Capture form. # ABOUTME: Templates for Egg Quick Capture forms.
# ABOUTME: Provides form components for recording egg collections. # ABOUTME: Provides form components for recording egg harvests and sales.
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
from fasthtml.common import H2, A, Div, Form, Hidden, Option, P from fasthtml.common import H1, H2, A, Div, Form, Hidden, Li, Option, P, Ul
from monsterui.all import Button, ButtonT, LabelInput, LabelSelect, LabelTextArea from monsterui.all import (
Button,
ButtonT,
LabelInput,
LabelSelect,
LabelTextArea,
TabContainer,
)
from ulid import ULID 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], locations: list[Location],
selected_location_id: str | None = None, selected_location_id: str | None = None,
error: str | None = None, error: str | None = None,
action: Callable[..., Any] | str = "/actions/product-collected", action: Callable[..., Any] | str = "/actions/product-collected",
) -> Div: ) -> Form:
"""Create the Egg Quick Capture form with Record Sale link. """Create the Harvest form for egg collection.
Args: Args:
locations: List of active locations for the dropdown. locations: List of active locations for the dropdown.
@@ -26,7 +108,7 @@ def egg_form(
action: Route function or URL string for form submission. action: Route function or URL string for form submission.
Returns: Returns:
Div containing the form and a link to Record Sale page. Form component for recording egg harvests.
""" """
# Build location options # Build location options
location_options = [ location_options = [
@@ -52,8 +134,8 @@ def egg_form(
cls="mb-4", cls="mb-4",
) )
form = Form( return Form(
H2("Record Eggs", cls="text-xl font-bold mb-4"), H2("Harvest Eggs", cls="text-xl font-bold mb-4"),
# Error message if present # Error message if present
error_component, error_component,
# Location dropdown # Location dropdown
@@ -83,21 +165,123 @@ def egg_form(
# Hidden nonce for idempotency # Hidden nonce for idempotency
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # 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) # Form submission via standard action/method (hx-boost handles AJAX)
action=action, action=action,
method="post", method="post",
cls="space-y-4", cls="space-y-4",
) )
return Div(
form, def sell_form(
Div( products: list[Product],
A( selected_product_code: str | None = "egg.duck",
"Record Sale", error: str | None = None,
href="/sell", action: Callable[..., Any] | str = "/actions/product-sold",
cls="text-sm text-blue-400 hover:text-blue-300 underline", ) -> Form:
), """Create the Sell form for recording sales.
cls="mt-4 text-center",
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))

View File

@@ -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 # Icon mapping by nav item ID
NAV_ICONS = { NAV_ICONS = {
"egg": EggIcon, "eggs": EggIcon,
"feed": FeedIcon, "feed": FeedIcon,
"move": MoveIcon, "move": MoveIcon,
"registry": RegistryIcon, "registry": RegistryIcon,
"cohort": CohortIcon, "cohort": CohortIcon,
"hatch": HatchIcon, "hatch": HatchIcon,
"menu": MenuIcon,
} }

View File

@@ -1,18 +1,16 @@
# ABOUTME: Bottom navigation component for AnimalTrack mobile UI. # ABOUTME: Bottom navigation component for AnimalTrack mobile UI.
# ABOUTME: Industrial farm aesthetic with large touch targets and high contrast. # 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 from animaltrack.web.templates.icons import NAV_ICONS
# Navigation items configuration # Navigation items configuration (simplified to 4 items)
NAV_ITEMS = [ NAV_ITEMS = [
{"id": "egg", "label": "Egg", "href": "/"}, {"id": "eggs", "label": "Eggs", "href": "/"},
{"id": "feed", "label": "Feed", "href": "/feed"}, {"id": "feed", "label": "Feed", "href": "/feed"},
{"id": "move", "label": "Move", "href": "/move"}, {"id": "move", "label": "Move", "href": "/move"},
{"id": "cohort", "label": "Cohort", "href": "/actions/cohort"}, {"id": "menu", "label": "Menu", "href": None}, # Opens drawer, no href
{"id": "hatch", "label": "Hatch", "href": "/actions/hatch"},
{"id": "registry", "label": "Registry", "href": "/registry"},
] ]
@@ -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: Args:
active_id: Currently active nav item ('egg', 'feed', 'move', 'registry') active_id: Currently active nav item ('eggs', 'feed', 'move')
Returns: Returns:
FT component for the bottom nav FT component for the bottom nav
@@ -84,21 +82,33 @@ def BottomNav(active_id: str = "egg"): # noqa: N802
if is_active: if is_active:
item_cls += "bg-stone-900/50 rounded-lg" item_cls += "bg-stone-900/50 rounded-lg"
link_cls = ( wrapper_cls = (
"relative flex-1 flex items-center justify-center min-h-[64px] " "relative flex-1 flex items-center justify-center min-h-[64px] "
"transition-all duration-150 active:scale-95 " "transition-all duration-150 active:scale-95 "
) )
if is_active: 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( return A(
Div( inner,
icon_fn(active=is_active),
Span(item["label"], cls=label_cls),
cls=item_cls,
),
href=item["href"], href=item["href"],
cls=link_cls, cls=wrapper_cls,
) )
return Div( return Div(
@@ -109,6 +119,6 @@ def BottomNav(active_id: str = "egg"): # noqa: N802
*[nav_item(item) for item in NAV_ITEMS], *[nav_item(item) for item in NAV_ITEMS],
cls="flex items-stretch bg-[#1a1a18] safe-area-pb", 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", id="bottom-nav",
) )

View File

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