From 0643dda10d3443d2252680e7846a7dce8f70b5a5 Mon Sep 17 00:00:00 2001 From: James Brechtel Date: Tue, 26 May 2026 07:38:15 -0400 Subject: [PATCH] Update recipe view page layout to match roux-recipe-mockup.html 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 --- roux-landing-mockup.html | 168 +++++++++++++ roux-recipe-mockup.html | 186 ++++++++++++++ src/Roux/Html.hs | 518 +++++++++++++++++++-------------------- 3 files changed, 608 insertions(+), 264 deletions(-) create mode 100644 roux-landing-mockup.html create mode 100644 roux-recipe-mockup.html diff --git a/roux-landing-mockup.html b/roux-landing-mockup.html new file mode 100644 index 0000000..575dc67 --- /dev/null +++ b/roux-landing-mockup.html @@ -0,0 +1,168 @@ + + + + +Roux — Landing page mockup + + + +
+ + + +
+
+
Don’t know what to cook?
+

Try Sheet-Pan Salmon & Asparagus

+
+
+ + +
+
+ +
+
+

Recently cooked

+ +
+
+
+
+
Chicken Piccata
+
May 26 · 2nd cook
+
+
+
+
Arugula Pesto Wheat Berries
+
May 21
+
+
+
+
Alain Ducasse’s Gougères
+
May 18
+
+
+
+ +
+
+

Recently added

+ +
+
+
+
+
Apple Cider Vinegar Dressing
+
Added May 24
+
+
+
+
Chicken Piccata
+
Added May 20
+
+
+
+
Arugula Pesto Chicken
+
Added May 14
+
+
+
+ +
+ + diff --git a/roux-recipe-mockup.html b/roux-recipe-mockup.html new file mode 100644 index 0000000..d58bdaf --- /dev/null +++ b/roux-recipe-mockup.html @@ -0,0 +1,186 @@ + + + + +Roux — Recipe page mockup + + + +
+ + + +

Chicken Piccata

+

With buttery angel hair pasta & dressed greens with parmesan

+ +
+
+ Last cooked May 26“Pretty good — lots of extra pasta.” +
+
+ + +
+
+ + + +
+ +
+ +
2
+ + +
    +
  • water
  • +
  • 2chicken breasts
  • +
  • 1lemon
  • +
  • ¼ cparsley
  • +
  • 1 Tcapers
  • +
  • 2 Tolive oil
  • +
  • 2 Tbutter
  • +
+ + +
+

1. Slice chicken breasts horizontally, season, and dredge in flour.

+

2. Sear in olive oil over medium-high until golden, about 3 min per side. Set aside.

+

3. Deglaze the pan with lemon juice and a splash of water; add butter and capers and simmer until glossy.

+

4. Return chicken to the pan, spoon sauce over, and rest 2 min before plating over angel hair.

+
+
+ + + +
+ +
+ + diff --git a/src/Roux/Html.hs b/src/Roux/Html.hs index 4324fc6..3ed83d6 100644 --- a/src/Roux/Html.hs +++ b/src/Roux/Html.hs @@ -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