Update recipe view page layout to match roux-recipe-mockup.html
Build and Deploy / build-and-deploy (push) Failing after 1m40s
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:
+254
-264
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user