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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -62,7 +62,7 @@ def registry_index(request: Request):
|
||||
species_list=species_list,
|
||||
),
|
||||
title="Registry - AnimalTrack",
|
||||
active_nav="registry",
|
||||
active_nav=None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
return A(
|
||||
Div(
|
||||
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(
|
||||
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",
|
||||
)
|
||||
|
||||
282
src/animaltrack/web/templates/sidebar.py
Normal file
282
src/animaltrack/web/templates/sidebar.py
Normal 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",
|
||||
)
|
||||
Reference in New Issue
Block a user