feat: phase 1 public site skeleton — layout, routes, CSS, logo pipeline
Ship base Jinja layout (header/nav/main/footer with skip link and aria-current), mobile-first single-file CSS using the ROADMAP palette tokens, and four public routes: /, /about, /contact, /shop. Blog index renders via a stable PostService.list_published() stub returning [] — Phase 2 only swaps the body. About is static placeholder copy, /contact ships an inert form plus a mailto: link driven by ADMIN_CONTACT_EMAIL, /shop shows a "Coming soon" card. Adds a Pillow-based scripts/generate_static_assets.py producing resized logo PNG + WebP, multi-size favicon.ico, and a 180x180 apple-touch-icon on a cream background. Outputs committed for a reproducible build. Also ship docs/MANUAL_TESTING.md with per-route / responsive / a11y / static- asset checklists, and mark Phase 1 complete in docs/ROADMAP.md.
This commit is contained in:
125
app/routes/public.py
Normal file
125
app/routes/public.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Public-facing HTTP routes.
|
||||
|
||||
Phase 1 scope:
|
||||
|
||||
- ``GET /`` — blog index (currently empty list from the stub service).
|
||||
- ``GET /about`` — static placeholder copy.
|
||||
- ``GET /contact`` — inert contact form UI + optional ``mailto:`` link.
|
||||
- ``GET /shop`` — "Coming soon" card.
|
||||
|
||||
Every handler is thin: it resolves its dependencies, calls any service
|
||||
methods it needs, and delegates rendering to a Jinja template. No HTML is
|
||||
constructed in Python.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.models.posts import PostSummary
|
||||
from app.services.posts import PostService, get_post_service
|
||||
|
||||
|
||||
# Module-level router. Mounted without a prefix by ``app.main.create_app``
|
||||
# so the routes below live at the site root.
|
||||
router: APIRouter = APIRouter(tags=["public"])
|
||||
|
||||
|
||||
def get_templates(request: Request) -> Jinja2Templates:
|
||||
"""Return the shared :class:`Jinja2Templates` instance.
|
||||
|
||||
The singleton is attached to ``app.state.templates`` in
|
||||
:func:`app.main.create_app`. Looking it up via ``request.app.state``
|
||||
(rather than importing from ``app.main``) avoids an import cycle and
|
||||
keeps the handlers test-friendly — tests can swap out the instance by
|
||||
mutating ``app.state.templates`` before issuing requests.
|
||||
"""
|
||||
return request.app.state.templates
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse, summary="Blog index")
|
||||
def home(
|
||||
request: Request,
|
||||
templates: Jinja2Templates = Depends(get_templates),
|
||||
posts: PostService = Depends(get_post_service),
|
||||
) -> HTMLResponse:
|
||||
"""Render the blog index with any published posts.
|
||||
|
||||
In Phase 1 the service returns an empty list, so the template shows a
|
||||
friendly "no posts yet" state. Phase 2 will populate the list from
|
||||
SQLite without any changes to this handler.
|
||||
"""
|
||||
# Query the service layer for the most recent published posts. The
|
||||
# template handles the empty-list case; we do not branch here.
|
||||
summaries: list[PostSummary] = posts.list_published()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"public/home.html",
|
||||
{"posts": summaries, "active_nav": "home"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/about", response_class=HTMLResponse, summary="About the farm")
|
||||
def about(
|
||||
request: Request,
|
||||
templates: Jinja2Templates = Depends(get_templates),
|
||||
) -> HTMLResponse:
|
||||
"""Render the static About page.
|
||||
|
||||
Copy is deliberately generic and does not reveal the farm's street
|
||||
address (Morrison, TN is mentioned; the physical address is not —
|
||||
see CLAUDE.md). Head Hen will replace this content via the Phase 4
|
||||
admin CMS.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"public/about.html",
|
||||
{"active_nav": "about"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/contact", response_class=HTMLResponse, summary="Contact the farm")
|
||||
def contact(
|
||||
request: Request,
|
||||
templates: Jinja2Templates = Depends(get_templates),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> HTMLResponse:
|
||||
"""Render the inert contact page.
|
||||
|
||||
The form fields are marked ``disabled`` and the form has no ``method``
|
||||
attribute — it is UI-only. If ``ADMIN_CONTACT_EMAIL`` is configured
|
||||
the template renders a ``mailto:`` link so visitors still have a way
|
||||
to reach the farm before Phase 5 wires up the real POST flow.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"public/contact.html",
|
||||
{
|
||||
"active_nav": "contact",
|
||||
# None when unset; the template hides the mailto link in that
|
||||
# case. We pass the value through settings so tests can
|
||||
# override it without touching environment variables.
|
||||
"contact_email": settings.admin_contact_email,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/shop", response_class=HTMLResponse, summary="Shop placeholder")
|
||||
def shop(
|
||||
request: Request,
|
||||
templates: Jinja2Templates = Depends(get_templates),
|
||||
) -> HTMLResponse:
|
||||
"""Render the "Coming soon" placeholder for the future shop.
|
||||
|
||||
The nav link to ``/shop`` remains enabled so visitors can preview the
|
||||
offering; it is the landing page itself that signals the shop is not
|
||||
yet live. Phase 7 replaces this template with the real catalog.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"public/shop.html",
|
||||
{"active_nav": "shop"},
|
||||
)
|
||||
Reference in New Issue
Block a user