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(
|
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,
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
|
||||||
username = auth.username if auth else None
|
|
||||||
if username:
|
|
||||||
defaults = UserDefaultsRepository(db).get(username, "collect_egg")
|
defaults = UserDefaultsRepository(db).get(username, "collect_egg")
|
||||||
if defaults:
|
if defaults:
|
||||||
selected_location_id = defaults.location_id
|
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,
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
return A(
|
inner = Div(
|
||||||
Div(
|
|
||||||
icon_fn(active=is_active),
|
icon_fn(active=is_active),
|
||||||
Span(item["label"], cls=label_cls),
|
Span(item["label"], cls=label_cls),
|
||||||
cls=item_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"],
|
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",
|
||||||
)
|
)
|
||||||
|
|||||||
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