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 @@
+
+
+
+
+
+
+
+
+
+
Don’t know what to cook?
+
Try Sheet-Pan Salmon & Asparagus
+
+
+
+
+
+
+
+
+
+
+
+
+
Chicken Piccata
+
May 26 · 2nd cook
+
+
+
+
Arugula Pesto Wheat Berries
+
May 21
+
+
+
+
Alain Ducasse’s Gougères
+
May 18
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
Chicken Piccata
+
With buttery angel hair pasta & dressed greens with parmesan
+
+
+
+
+
+
+
+
+
Serves
+
2
+
+
Ingredients
+
+ - —water
+ - 2chicken breasts
+ - 1lemon
+ - ¼ cparsley
+ - 1 Tcapers
+ - 2 Tolive oil
+ - 2 Tbutter
+
+
+
Method
+
+
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