Update recipe view page layout to match roux-recipe-mockup.html
Build and Deploy / build-and-deploy (push) Failing after 1m40s

Layout changes:
- Nav matching landing page style (ROUX logo + Recipes + Cook History)
- Large serif title with inline editing preserved
- Subtitle from recipe description
- Meta strip: rounded background with last cook info + source link + Log meal button
- Full-width hero image with upload/replace overlay
- 2-column body grid (1.5fr ingredients+method | 1fr cook history rail)
- Flat ingredient list with quantities (no checkboxes)
- Numbered method paragraphs (no checkboxes, timers preserved)
- Cook history rail on right with + Add button
- Tags displayed below the grid
- Responsive: single column on mobile

Architecture:
- Removed: renderMetaBar, renderCookLog, renderRecentCookEntries,
  renderRecentEntry, renderEntry, renderIngredientGroup, renderIngredientRow,
  collectSectionIngredients, dedupFirst, renderMethodSections, renderMethodStep,
  isEmptyStep, isBlankText, isMethodSection, sectionMethodSteps,
  renderMarginalNote, collectNotes, bodyItemSteps, pairWith
- Added: recipePageStyles, rpHeroImage, rpLastCookInfo, formatFullDate,
  rpIngredientList, rpNamedIngredientSection, collectIngredients,
  rpIngredientItems, rpMethodList, rpRenderMethodStep, rpHistoryList,
  rpRenderHistoryEntry
- Updated cookLogJs to use .roux-record-trigger class for dual trigger buttons
- Removed unused imports (Data.Function, catMaybes)
- All hlint warnings fixed, 51 tests passing
This commit is contained in:
2026-05-26 07:38:15 -04:00
parent 6a7374c912
commit 0643dda10d
3 changed files with 608 additions and 264 deletions
+254 -264
View File
@@ -23,9 +23,8 @@ module Roux.Html (
import Control.Monad (unless)
import Data.ByteString.Lazy (ByteString)
import qualified Data.ByteString.Lazy as LB
import Data.Function ((&))
import qualified Data.List.NonEmpty as NE
import Data.Maybe (catMaybes, fromMaybe)
import Data.Maybe (fromMaybe)
import Data.Ratio (denominator, numerator)
import Data.Text (Text)
import qualified Data.Text as T
@@ -61,10 +60,7 @@ newtype ImportError = ImportError Text
-- Helpers used throughout
-- ---------------------------------------------------------------------------
-- | Pair a label with a value if present.
pairWith :: a -> Maybe b -> Maybe (a, b)
pairWith _ Nothing = Nothing
pairWith a (Just b) = Just (a, b)
{- | Format a quantity for display.
| Format a quantity for natural display (e.g. @4 cups@, @1/2 tsp@).
@@ -652,7 +648,7 @@ cookLogJs =
T.unlines
[ "(function(){"
, "'use strict';"
, "var btn = document.getElementById('roux-record-btn');"
, "var btns = document.querySelectorAll('.roux-record-trigger');"
, "var modal = document.getElementById('roux-record-modal');"
, "var backdrop = document.getElementById('roux-modal-backdrop');"
, "var cancel = document.getElementById('roux-modal-cancel');"
@@ -660,7 +656,7 @@ cookLogJs =
, "var dateInput = document.getElementById('roux-record-date');"
, "var commentInput = document.getElementById('roux-record-comment');"
, "var errorEl = document.getElementById('roux-record-error');"
, "if (!btn || !modal || !form) return;"
, "if (!btns.length || !modal || !form) return;"
, ""
, "function showModal() {"
, " dateInput.value = new Date().toISOString().slice(0,10);"
@@ -674,7 +670,7 @@ cookLogJs =
, " modal.style.display = 'none';"
, "}"
, ""
, "btn.addEventListener('click', showModal);"
, "btns.forEach(function(b) { b.addEventListener('click', showModal); });"
, "if (cancel) cancel.addEventListener('click', hideModal);"
, "if (backdrop) backdrop.addEventListener('click', hideModal);"
, ""
@@ -891,113 +887,138 @@ recipePage info entries =
H.script ! A.type_ "text/javascript" $ H.preEscapedText titleEditJs
H.script ! A.type_ "text/javascript" $ H.preEscapedText cookLogJs
-- | Render the cook log section in the marginalia column.
renderCookLog :: FilePath -> [CookLog.CookEntry] -> Html
renderCookLog _ [] = pure ()
renderCookLog _filename entries = do
H.h2 "Cook history"
H.div ! A.class_ "roux-cook-entries" $ do
mapM_ renderEntry entries
-- | Render the most recent cook entries under the "Record meal" button.
renderRecentCookEntries :: [CookLog.CookEntry] -> Html
renderRecentCookEntries [] = pure ()
renderRecentCookEntries entries = do
H.div ! A.style "margin-top: 0.4rem; font-size: 0.75rem; color: var(--roux-muted); line-height: 1.5;" $ do
H.span "Recent: "
mapM_ renderRecentEntry (take 5 entries)
renderRecentEntry :: CookLog.CookEntry -> Html
renderRecentEntry entry = do
H.span ! A.style "color: var(--roux-muted);" $ H.toHtml (formatDay (CookLog.ceCookedDate entry))
unless (T.null (CookLog.ceComment entry)) $
H.span ! A.style "color: var(--roux-text);" $
": " >> H.toHtml (CookLog.ceComment entry)
H.span ", "
-- | Render a single cook log entry.
renderEntry :: CookLog.CookEntry -> Html
renderEntry entry = do
H.p ! A.style "margin: 0.3rem 0; font-size: 0.9rem;" $ do
H.strong ! A.style "display: block; text-align: center; color: var(--roux-muted);" $
H.toHtml ("\x2014\x2014 " <> formatDay (CookLog.ceCookedDate entry) <> " \x2014\x2014")
unless (T.null (CookLog.ceComment entry)) $
H.span ! A.style "display: block; margin-top: 0.2rem;" $
H.toHtml (CookLog.ceComment entry)
-- | Format a Day as "Mon DD" e.g. "Mar 15".
formatDay :: Day -> Text
formatDay = T.pack . Time.formatTime Time.defaultTimeLocale "%b %e"
-- | Render the body of a recipe page (no page shell).
-- | Recipe page CSS matching the roux-recipe-mockup.html layout.
recipePageStyles :: Text
recipePageStyles =
T.unlines
[ ".rp-page { max-width: 980px; margin: 0 auto; padding: 36px 48px 80px; }"
, ".rp-nav { display: flex; justify-content: space-between; align-items: center; border-bottom: 0.5px solid rgba(61,44,30,0.18); padding-bottom: 12px; margin-bottom: 20px; font-size: 12px; letter-spacing: 0.12em; color: #6B5847; text-transform: uppercase; }"
, ".rp-logo { font-family: \"Fraunces\", serif; font-size: 22px; font-weight: 500; letter-spacing: 0.08em; color: var(--roux-text); text-decoration: none; }"
, ".rp-nav-links { display: flex; gap: 28px; }"
, ".rp-nav-links a { color: #6B5847; text-decoration: none; }"
, ".rp-nav-links a:hover { color: var(--roux-accent); }"
, ""
, ".rp-course { font-size: 11px; letter-spacing: 0.15em; text-transform: uppercase; color: var(--roux-accent); opacity: 0.65; margin: 0 0 4px; }"
, ".rp-title { font-size: 38px; line-height: 1.05; font-family: \"Fraunces\", serif; font-weight: 500; margin: 0; }"
, ".rp-title.roux-recipe-title:hover::after { content: '\\270E'; font-size: 0.7rem; opacity: 0.3; margin-left: 0.5rem; vertical-align: super; }"
, ".rp-sub { font-size: 16px; color: #6B5847; margin: 8px 0 16px; }"
, ""
, ".rp-meta-strip { display: flex; align-items: center; gap: 14px; background: rgba(61,40,23,0.05); border-radius: 10px; padding: 12px 18px; margin-bottom: 24px; font-size: 14px; }"
, ".rp-meta-strip-last { color: #6B5847; flex: 1; min-width: 0; }"
, ".rp-meta-strip-last strong { font-weight: 500; color: var(--roux-text); }"
, ".rp-meta-strip-last .rp-last-note { font-style: italic; }"
, ".rp-meta-strip-actions { display: flex; gap: 14px; align-items: center; flex-shrink: 0; }"
, ""
, ".rp-btn { background: var(--roux-accent); color: #FDF6E7; border: none; border-radius: 8px; padding: 9px 16px; font-size: 13px; font-weight: 500; cursor: pointer; font-family: inherit; }"
, ".rp-btn:hover { filter: brightness(0.95); }"
, ".rp-btn-ghost { background: none; border: none; color: var(--roux-accent); font-size: 13px; cursor: pointer; padding: 0; font-family: inherit; text-decoration: none; }"
, ".rp-btn-ghost:hover { text-decoration: underline; }"
, ""
, ".rp-hero { width: 100%; aspect-ratio: 2.2; border-radius: 10px; background-size: cover; background-position: center; margin-bottom: 32px; position: relative; }"
, ".rp-hero-placeholder { background: linear-gradient(135deg, #E8B86A, #C9893F 50%, #8A5A2A); cursor: pointer; display: flex; align-items: center; justify-content: center; }"
, ".rp-hero-upload { font-size: 14px; color: rgba(61,44,30,0.4); }"
, ".rp-hero-overlay { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(61,44,30,0.6); color: #F5EFE0; font-size: 0.7rem; text-align: center; padding: 4px 0; cursor: pointer; opacity: 0; transition: opacity 0.15s; border-radius: 0 0 10px 10px; }"
, ".rp-hero:hover .rp-hero-overlay { opacity: 1; }"
, ".rp-hero-wrap { position: relative; margin-bottom: 32px; }"
, ".rp-hero-wrap .rp-hero { margin-bottom: 0; }"
, ""
, ".rp-body-grid { display: grid; grid-template-columns: 1.5fr 1fr; gap: 40px; margin-bottom: 32px; }"
, ""
, ".rp-section-label { font-size: 12px; letter-spacing: 0.12em; color: #8A7560; text-transform: uppercase; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: baseline; }"
, ".rp-serves { font-family: \"Fraunces\", serif; font-size: 28px; margin-bottom: 22px; }"
, ""
, ".rp-ingredients { list-style: none; padding: 0; margin: 0 0 28px; font-size: 15px; line-height: 1.9; }"
, ".rp-ingredients li { display: flex; gap: 14px; align-items: baseline; padding: 4px 0; border-bottom: 0.5px dashed rgba(61,40,23,0.15); }"
, ".rp-ingredients li:last-child { border-bottom: none; }"
, ".rp-ingredients .rp-qty { color: #8A7560; min-width: 48px; font-variant-numeric: tabular-nums; }"
, ".rp-ing-section { font-size: 13px; font-weight: 500; color: #6B5847; margin: 16px 0 6px; letter-spacing: 0.03em; }"
, ""
, ".rp-method { font-size: 15px; line-height: 1.7; color: #4A3525; }"
, ".rp-method p { margin: 0 0 14px; }"
, ".rp-method .rp-step { font-weight: 500; color: var(--roux-text); margin-right: 4px; }"
, ".rp-method .rp-comment { color: #8A7560; font-size: 14px; }"
, ".rp-method .rp-note { color: #8A7560; font-size: 13px; }"
, ""
, ".rp-history-list { display: flex; flex-direction: column; gap: 0; }"
, ".rp-history-entry { padding: 12px 0; border-bottom: 0.5px solid rgba(61,40,23,0.15); }"
, ".rp-history-entry:last-child { border-bottom: none; }"
, ".rp-history-date { font-family: \"Fraunces\", serif; font-size: 15px; font-weight: 500; color: var(--roux-text); margin-bottom: 2px; }"
, ".rp-history-note { font-size: 13px; color: #6B5847; line-height: 1.5; }"
, ".rp-history-note.empty { color: #8A7560; font-style: italic; }"
, ".rp-history-empty { font-size: 13px; color: #8A7560; font-style: italic; }"
, ""
, ".rp-tags { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 24px; }"
, ".rp-tag { font-size: 12px; color: var(--roux-accent); border: 1px solid rgba(184,92,56,0.25); border-radius: 4px; padding: 2px 10px; }"
, ""
, "@media (max-width: 720px) { .rp-page { padding: 24px 24px 60px; } .rp-meta-strip { flex-direction: column; align-items: flex-start; } .rp-meta-strip-actions { width: 100%; justify-content: flex-end; } .rp-body-grid { grid-template-columns: 1fr; gap: 32px; } .rp-title { font-size: 30px; } }"
]
-- | Render the body of a recipe page (no page shell) -- mockup-compliant layout.
renderRecipe :: FilePath -> Recipe -> [CookLog.CookEntry] -> Html
renderRecipe filename recipe entries = do
H.nav ! A.class_ "roux-navbar" $ do
H.ul $ H.li $ H.a ! A.class_ "roux-logo" ! A.href "/" $ "Roux"
H.ul $ H.li $ H.a ! A.href "/recipes" $ "Recipes"
let meta = recipeMetadata recipe
course = metaCourse meta
desc = metaDescription meta
case course of
Just c -> H.p ! A.class_ "roux-course" $ H.toHtml c
Nothing -> pure ()
H.div ! A.style "display: flex; justify-content: space-between; align-items: baseline; gap: 1rem;" $ do
H.h1 ! A.class_ "roux-recipe-title" $ H.toHtml (titleText recipe)
H.div ! A.style "text-align: right;" $ do
H.div ! A.style "display: flex; gap: 0.75rem; align-items: center; justify-content: flex-end;" $ do
case metaSource meta of
Just url -> H.a ! A.class_ "roux-source-link" ! A.href (H.toValue url) ! A.target "_blank" ! A.rel "noopener noreferrer" $ do
"↗ Original"
Nothing -> pure ()
H.button
! A.class_ "roux-record-btn"
! A.id "roux-record-btn"
! H.dataAttribute "filename" (H.toValue (T.pack filename))
$ "+ Record meal"
renderRecentCookEntries entries
H.div ! A.class_ "roux-desc-row" $ do
case desc of
Just d -> H.p ! A.class_ "roux-desc" $ H.toHtml d
H.style $ H.toHtml recipePageStyles
H.div ! A.class_ "rp-page" $ do
-- Nav (matching landing page style)
H.nav ! A.class_ "rp-nav" $ do
H.a ! A.class_ "rp-logo" ! A.href "/" $ "ROUX"
H.div ! A.class_ "rp-nav-links" $ do
H.a ! A.href "/recipes" $ "Recipes"
H.a ! A.href "/cook-history" $ "Cook History"
let meta = recipeMetadata recipe
course = metaCourse meta
desc = metaDescription meta
-- Course label
case course of
Just c -> H.p ! A.class_ "rp-course" $ H.toHtml c
Nothing -> pure ()
case metaImage meta of
Just img -> do
H.div ! A.class_ "roux-image-wrap" $ do
H.img ! A.src (H.toValue img) ! A.alt (H.toValue (titleText recipe))
H.label ! A.class_ "roux-image-overlay" ! A.for "roux-image-input" $ "Replace"
H.input ! A.type_ "file" ! A.accept "image/*" ! A.id "roux-image-input" ! H.dataAttribute "filename" (H.toValue (T.pack filename)) ! A.style "display: none;"
Nothing -> do
H.div ! A.class_ "roux-image-wrap roux-image-empty" $ do
H.label ! A.class_ "roux-image-placeholder" ! A.for "roux-image-input" $ "Upload photo"
H.input ! A.type_ "file" ! A.accept "image/*" ! A.id "roux-image-input" ! H.dataAttribute "filename" (H.toValue (T.pack filename)) ! A.style "display: none;"
renderMetaBar meta
let sections = NE.toList (recipeSections recipe)
notes = collectNotes recipe
H.div ! A.class_ "roux-grid" $ do
-- Ingredients column
H.div $ do
H.h2 "Ingredients"
mapM_ renderIngredientGroup sections
-- Method column
H.div $ do
H.h2 "Method"
renderMethodSections sections
-- Marginalia column
H.div ! A.class_ "roux-marginalia" $ do
case metaSource meta of
Just url -> H.div ! A.class_ "note" $ do
H.h2 "Source"
H.p $ H.toHtml url
Nothing -> pure ()
let recipeTags = metaTags meta
unless (null recipeTags) $ do
H.div ! A.class_ "note" $ do
H.h2 "Tags"
H.p $ H.toHtml (T.intercalate " · " recipeTags)
renderCookLog filename entries
unless (null notes) $ do
H.h2 "Notes"
mapM_ renderMarginalNote notes
-- Record meal modal
-- Title (inline-editable via existing JS)
H.h1 ! A.class_ "roux-recipe-title rp-title" $ H.toHtml (titleText recipe)
-- Subtitle (from description)
case desc of
Just d -> H.p ! A.class_ "rp-sub" $ H.toHtml d
Nothing -> pure ()
-- Meta strip: last cook + source + log meal
H.div ! A.class_ "rp-meta-strip" $ do
H.div ! A.class_ "rp-meta-strip-last" $ rpLastCookInfo entries
H.div ! A.class_ "rp-meta-strip-actions" $ do
case metaSource meta of
Just url -> H.a ! A.class_ "rp-btn-ghost" ! A.href (H.toValue url) ! A.target "_blank" $ "\x2197 Original"
Nothing -> pure ()
H.button ! A.class_ "rp-btn roux-record-trigger" ! A.id "roux-record-btn" ! H.dataAttribute "filename" (H.toValue (T.pack filename)) $ "+ Log meal"
-- Hero image
rpHeroImage meta filename
-- Body grid
H.div ! A.class_ "rp-body-grid" $ do
-- Left column: servings, ingredients, method
H.div $ do
case metaServings meta of
Just s -> do
H.div ! A.class_ "rp-section-label" $ "Serves"
H.div ! A.class_ "rp-serves" $ H.toHtml (showServings s)
Nothing -> pure ()
let sections = NE.toList (recipeSections recipe)
H.div ! A.class_ "rp-section-label" $ "Ingredients"
rpIngredientList sections
H.div ! A.class_ "rp-section-label" $ "Method"
rpMethodList sections
-- Right column: cook history rail
H.aside $ do
H.div ! A.class_ "rp-section-label" $ do
H.span "Cook history"
H.button ! A.class_ "rp-btn-ghost roux-record-trigger" ! H.dataAttribute "filename" (H.toValue (T.pack filename)) $ "+ Add"
rpHistoryList entries
-- Tags
let tags = metaTags meta
unless (null tags) $
H.div ! A.class_ "rp-tags" $ mapM_ (\t -> H.span ! A.class_ "rp-tag" $ H.toHtml t) tags
-- Record meal modal (unchanged)
H.div ! A.id "roux-record-modal" ! A.style "display: none;" $ do
H.div ! A.class_ "roux-modal-backdrop" ! A.id "roux-modal-backdrop" $ ""
H.div ! A.class_ "roux-modal-dialog" $ do
@@ -1012,39 +1033,136 @@ renderRecipe filename recipe entries = do
H.button ! A.type_ "button" ! A.class_ "roux-modal-cancel" ! A.id "roux-modal-cancel" $ "Cancel"
H.button ! A.type_ "submit" ! A.class_ "roux-modal-confirm" $ "Log it"
-- ---------------------------------------------------------------------------
-- Recipe page helpers
-- ---------------------------------------------------------------------------
-- | Render the hero image with upload/replace overlay.
rpHeroImage :: Metadata -> FilePath -> Html
rpHeroImage meta filename =
H.div ! A.class_ "rp-hero-wrap" $ do
case metaImage meta of
Just img ->
H.div ! A.class_ "rp-hero" ! A.style (H.toValue ("background-image: url(" <> img <> ")")) $ ""
Nothing ->
H.div ! A.class_ "rp-hero rp-hero-placeholder" $ ""
H.label ! A.class_ "rp-hero-overlay" ! A.for "roux-image-input" $
case metaImage meta of
Just _ -> "Replace"
Nothing -> "Upload photo"
H.input ! A.type_ "file" ! A.accept "image/*" ! A.id "roux-image-input" ! H.dataAttribute "filename" (H.toValue (T.pack filename)) ! A.style "display: none;"
-- | Render the last cook info for the meta strip.
rpLastCookInfo :: [CookLog.CookEntry] -> Html
rpLastCookInfo [] =
H.span "Not yet cooked"
rpLastCookInfo (latest : _) = H.span $ do
H.strong $ H.toHtml ("Last cooked " <> formatFullDate (CookLog.ceCookedDate latest))
unless (T.null (CookLog.ceComment latest)) $ do
" \x2014 "
H.span ! A.class_ "rp-last-note" $ H.toHtml (CookLog.ceComment latest)
-- | Format a Day as "Month DD, YYYY" e.g. "May 26, 2026".
formatFullDate :: Day -> Text
formatFullDate = T.pack . Time.formatTime Time.defaultTimeLocale "%B %e, %Y"
-- | Render ingredients as a flat list grouped by section (mockup style).
rpIngredientList :: [Section] -> Html
rpIngredientList sections = do
let ingSects = filter isIngSection sections
unnamed = [s | s <- ingSects, isUnnamedIngSection s]
named = [s | s <- ingSects, not (isUnnamedIngSection s)]
-- Unnamed sections first, then named
unless (null unnamed) $
H.ul ! A.class_ "rp-ingredients" $
mapM_ rpIngredientItems (concatMap collectIngredients unnamed)
mapM_ rpNamedIngredientSection named
where
isIngSection s =
case sectionName s of
Just n -> let lower = T.toLower n in lower `elem` ["ingredients", "ingredient"]
Nothing -> not . Prelude.null $ collectIngredients s
isUnnamedIngSection s = Prelude.null (sectionName s)
-- | Render a named ingredient section with a sub-heading.
rpNamedIngredientSection :: Section -> Html
rpNamedIngredientSection section = do
let ings = collectIngredients section
unless (null ings) $ do
case sectionName section of
Just n -> H.p ! A.class_ "rp-ing-section" $ H.toHtml n
Nothing -> pure ()
H.ul ! A.class_ "rp-ingredients" $ mapM_ rpIngredientItems ings
-- | Collect ingredient (name, quantity) pairs from a section.
collectIngredients :: Section -> [(Text, Maybe Quantity)]
collectIngredients section =
NE.toList (sectionBody section)
>>= \case
SecStep step ->
let ings = [i | StepIngredient i <- unStep step]
in [(ingName i, ingQuantity i) | i <- ings]
_ -> []
-- | Render one ingredient item in the mockup-style list.
rpIngredientItems :: (Text, Maybe Quantity) -> Html
rpIngredientItems (name, mQty) =
H.li $ do
H.span ! A.class_ "rp-qty" $
case mQty of
Just q -> H.toHtml (showQuantity q)
Nothing -> H.toHtml ("\x2014" :: Text)
H.span $ H.toHtml name
-- | Render numbered method steps as plain paragraphs (mockup style).
rpMethodList :: [Section] -> Html
rpMethodList sections = do
let methodSects = filter (not . isIngSection) sections
allSteps = concatMap extractSteps methodSects
meaningful = filter (not . isEmptyStep) allSteps
H.div ! A.class_ "rp-method" $ mapM_ (uncurry rpRenderMethodStep) (zip [1 ..] meaningful)
where
extractSteps s = Prelude.map (sectionName s,) (NE.toList (sectionBody s))
isEmptyStep (_, SecStep step) = null (unStep step) || all isBlankText (unStep step)
isEmptyStep _ = False
isBlankText (StepText t) = T.all (== ' ') t
isBlankText _ = False
isIngSection s =
case sectionName s of
Just n -> T.toLower n `elem` ["ingredients", "ingredient"]
Nothing -> False
-- | Render one step as a numbered paragraph.
rpRenderMethodStep :: Int -> (Maybe Text, SectionBodyItem) -> Html
rpRenderMethodStep stepNum (_, SecStep step) =
H.p $ do
H.span ! A.class_ "rp-step" $ H.toHtml (T.pack (show stepNum <> ". "))
mapM_ renderStepItem (unStep step)
rpRenderMethodStep _ (_, SecComment t) =
H.p ! A.class_ "rp-comment" $ H.em $ H.toHtml ("-- " <> t)
rpRenderMethodStep _ (_, SecNote t) =
H.p ! A.class_ "rp-note" $ H.small $ H.toHtml ("> " <> t)
-- | Render the cook history rail (right column).
rpHistoryList :: [CookLog.CookEntry] -> Html
rpHistoryList entries
| null entries = H.div ! A.class_ "rp-history-empty" $ "No cook history yet."
| otherwise = H.div ! A.class_ "rp-history-list" $ mapM_ rpRenderHistoryEntry entries
-- | Render a single history entry.
rpRenderHistoryEntry :: CookLog.CookEntry -> Html
rpRenderHistoryEntry entry =
H.div ! A.class_ "rp-history-entry" $ do
H.div ! A.class_ "rp-history-date" $ H.toHtml (formatFullDate (CookLog.ceCookedDate entry))
H.div ! A.class_ ("rp-history-note" <> if T.null (CookLog.ceComment entry) then " empty" else "") $
if T.null (CookLog.ceComment entry)
then "No notes"
else H.toHtml (CookLog.ceComment entry)
-- | Extract display title from recipe metadata or fallback.
titleText :: Recipe -> Text
titleText recipe = fromMaybe "Untitled Recipe" (metaTitle (recipeMetadata recipe))
-- ---------------------------------------------------------------------------
-- Metadata bar
-- ---------------------------------------------------------------------------
-- | Render the horizontal metadata bar (serves, prep, cook, total).
renderMetaBar :: Metadata -> Html
renderMetaBar meta = do
let items =
catMaybes
[ pairWith (T.pack "Serves") (showServings <$> metaServings meta)
, pairWith (T.pack "Prep") (showDuration <$> metaPrepTime meta)
, pairWith (T.pack "Cook") (showDuration <$> metaCookTime meta)
, pairWith (T.pack "Total") (showDuration <$> metaTotalTime meta)
]
unless (null items) $
H.div ! A.class_ "roux-meta" $
mapM_
( \(label, value) ->
H.div ! A.class_ "roux-meta-item" $ do
H.p ! A.class_ "label" $ H.toHtml label
H.p ! A.class_ "value" $ H.toHtml value
)
items
-- Tags
let tags = metaTags meta
unless (null tags) $
H.p $
mapM_ (\t -> H.span ! A.class_ "tag" $ H.toHtml t) tags
-- | Format servings info.
showServings :: (Int, Maybe Text) -> Text
showServings (n, Nothing) = T.pack (show n)
@@ -1084,104 +1202,6 @@ durationToSeconds d =
toDouble :: Rational -> Double
toDouble r = fromIntegral (numerator r) / fromIntegral (denominator r)
-- ---------------------------------------------------------------------------
-- Ingredients column
-- ---------------------------------------------------------------------------
-- | Render ingredients grouped by section.
renderIngredientGroup :: Section -> Html
renderIngredientGroup section = do
let ings = collectSectionIngredients section
unless (null ings) $ do
case sectionName section of
Just n -> H.p ! A.class_ "roux-subsection" $ H.toHtml n
Nothing -> pure ()
H.div $ mapM_ renderIngredientRow ings
-- | Render one ingredient row with checkbox.
renderIngredientRow :: (Text, Maybe Quantity) -> Html
renderIngredientRow (name, qty) =
H.label ! A.class_ "roux-ingredient-row" $ do
H.input ! A.type_ "checkbox"
H.span ! A.class_ "name" $ do
case qty of
Just q -> H.span ! A.class_ "qty" $ H.toHtml (showQuantity q <> " ")
Nothing -> pure ()
H.toHtml name
-- | Collect unique ingredients from a single section.
collectSectionIngredients :: Section -> [(Text, Maybe Quantity)]
collectSectionIngredients section =
let ings =
NE.toList (sectionBody section)
>>= bodyItemSteps
>>= unStep
>>= \case
StepIngredient i -> [i]
_ -> []
in dedupFirst ings []
-- | Deduplicate ingredients by name, keeping first occurrence.
dedupFirst :: [Ingredient] -> [(Text, Maybe Quantity)] -> [(Text, Maybe Quantity)]
dedupFirst [] acc = reverse acc
dedupFirst (i : rest) acc =
if any ((== ingName i) . fst) acc
then dedupFirst rest acc
else dedupFirst rest ((ingName i, ingQuantity i) : acc)
-- ---------------------------------------------------------------------------
-- Method column
-- ---------------------------------------------------------------------------
-- | Render the method column: all sections with numbered steps.
renderMethodSections :: [Section] -> Html
renderMethodSections sections = do
let allSteps = concatMap sectionMethodSteps (filter isMethodSection sections)
-- Exclude empty/whitespace-only steps (from leading newlines in
-- Cooklang files) so step numbering always starts at 1.
meaningful = filter (not . isEmptyStep) allSteps
mapM_ (uncurry renderMethodStep) (zip [1 ..] meaningful)
-- | Is this a SectionBodyItem that contains no meaningful step content?
isEmptyStep :: (Maybe Text, SectionBodyItem) -> Bool
isEmptyStep (_, SecStep step) = null (unStep step) || all isBlankText (unStep step)
isEmptyStep _ = False
-- | Is a StepItem just whitespace?
isBlankText :: StepItem -> Bool
isBlankText (StepText t) = T.all (== ' ') t
isBlankText _ = False
-- | Is this section a method section (not an ingredient listing)?
isMethodSection :: Section -> Bool
isMethodSection section =
case sectionName section of
Just n -> T.toLower n `notElem` ["ingredients", "ingredient"]
Nothing -> True
-- | Extract (section name, body items) from a section for the method column.
sectionMethodSteps :: Section -> [(Maybe Text, SectionBodyItem)]
sectionMethodSteps section =
let name = sectionName section
in Prelude.map (name,) (NE.toList (sectionBody section))
-- | Render one step in the method column (with step number and checkbox).
renderMethodStep :: Int -> (Maybe Text, SectionBodyItem) -> Html
renderMethodStep stepNum (_, SecStep step) = do
H.label ! A.class_ "roux-step" $ do
H.input ! A.type_ "checkbox"
H.div $ do
H.p ! A.class_ "step-label" $ "Step " <> H.toHtml (T.pack (show stepNum))
H.p ! A.class_ "step-text" $ mapM_ renderStepItem (unStep step)
renderMethodStep _ (_, SecComment t) =
H.div ! A.class_ "roux-step" $
H.div $ do
H.p ! A.class_ "step-text" $ H.em $ H.toHtml ("-- " <> t)
renderMethodStep _ (_, SecNote t) =
H.div ! A.class_ "roux-step" $
H.div $ do
H.p ! A.class_ "step-text" $ H.small $ H.toHtml ("> " <> t)
-- ---------------------------------------------------------------------------
-- Step items (inline rendering within a step)
-- ---------------------------------------------------------------------------
@@ -1218,36 +1238,6 @@ renderStepItem (StepComment t) =
renderStepItem StepBreak =
H.br
-- ---------------------------------------------------------------------------
-- Notes section
-- ---------------------------------------------------------------------------
{- | Render a note with a decorative ornament.
| Render a note in the marginalia column (compact, no ornament).
-}
renderMarginalNote :: Text -> Html
renderMarginalNote t =
H.div ! A.class_ "note" $ H.p $ H.toHtml t
-- | Collect all note texts from the recipe.
collectNotes :: Recipe -> [Text]
collectNotes recipe =
recipe
& recipeSections
& NE.toList
>>= NE.toList . sectionBody
>>= \case
SecNote t -> [t]
_ -> []
-- ---------------------------------------------------------------------------
-- Shared helpers for both columns
-- ---------------------------------------------------------------------------
-- | Extract step items from a body item (returns empty for comments/notes).
bodyItemSteps :: SectionBodyItem -> [Step]
bodyItemSteps (SecStep s) = [s]
bodyItemSteps _ = []
-- ---------------------------------------------------------------------------
-- URL encoding