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:
@@ -0,0 +1,168 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Roux — Landing page mockup</title>
|
||||
<style>
|
||||
:root {
|
||||
--cream: #F5EBD3;
|
||||
--cream-light: #FDF6E7;
|
||||
--brown-dark: #3D2817;
|
||||
--brown-mid: #4A3525;
|
||||
--brown-soft: #6B5847;
|
||||
--brown-muted: #8A7560;
|
||||
--terracotta: #C5532E;
|
||||
--terracotta-soft: rgba(197, 83, 46, 0.08);
|
||||
--rule: rgba(61, 40, 23, 0.18);
|
||||
--rule-soft: rgba(61, 40, 23, 0.12);
|
||||
--font-serif: Georgia, "Iowan Old Style", "Times New Roman", serif;
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; background: var(--cream); font-family: var(--font-sans); color: var(--brown-dark); }
|
||||
.page { max-width: 980px; margin: 0 auto; padding: 36px 48px 80px; }
|
||||
|
||||
.nav { display: flex; justify-content: space-between; align-items: center;
|
||||
border-bottom: 0.5px solid var(--rule); padding-bottom: 12px; margin-bottom: 32px;
|
||||
font-size: 12px; letter-spacing: 0.12em; color: var(--brown-soft); text-transform: uppercase; }
|
||||
.logo { font-family: var(--font-serif); font-size: 22px; font-weight: 500; letter-spacing: 0.08em; color: var(--brown-dark); }
|
||||
.nav-links { display: flex; gap: 28px; }
|
||||
.nav-links a { color: var(--brown-soft); text-decoration: none; cursor: pointer; }
|
||||
.nav-links a.active { color: var(--terracotta); }
|
||||
.nav-links a:hover { color: var(--terracotta); }
|
||||
|
||||
h1, h2, h3 { font-family: var(--font-serif); font-weight: 500; color: var(--brown-dark); margin: 0; }
|
||||
h1.recipe-title { font-size: 32px; line-height: 1.1; }
|
||||
h3.shelf-title { font-size: 20px; }
|
||||
|
||||
.shuffle-card {
|
||||
background: var(--terracotta-soft);
|
||||
border-radius: 12px;
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
.shuffle-meta { font-size: 12px; letter-spacing: 0.12em; color: var(--brown-muted); text-transform: uppercase; margin-bottom: 4px; }
|
||||
.shuffle-card h3 { font-size: 20px; }
|
||||
.shuffle-actions { display: flex; gap: 14px; align-items: center; flex-shrink: 0; }
|
||||
|
||||
.btn {
|
||||
background: var(--terracotta); color: var(--cream-light);
|
||||
border: none; border-radius: 8px; padding: 10px 18px;
|
||||
font-size: 13px; font-weight: 500; cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn:hover { filter: brightness(0.95); }
|
||||
.btn-ghost {
|
||||
background: none; border: none; color: var(--terracotta);
|
||||
font-size: 13px; cursor: pointer; padding: 0; font-family: inherit;
|
||||
}
|
||||
.btn-ghost:hover { text-decoration: underline; }
|
||||
|
||||
.shelf-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 14px; }
|
||||
.shelf { margin-bottom: 36px; }
|
||||
.shelf-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
|
||||
|
||||
.card { cursor: pointer; }
|
||||
.card-image {
|
||||
width: 100%; aspect-ratio: 1.3; border-radius: 8px;
|
||||
background-size: cover; background-position: center;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.card:hover .card-image { opacity: 0.92; }
|
||||
.card-title { font-family: var(--font-serif); font-size: 17px; font-weight: 500; color: var(--brown-dark); margin: 10px 0 4px; }
|
||||
.card-meta { font-size: 12px; color: var(--brown-muted); }
|
||||
|
||||
/* placeholder gradients — swap with real images */
|
||||
.img-1 { background: linear-gradient(135deg, #E8B86A, #C9893F 60%, #8A5A2A); }
|
||||
.img-2 { background: linear-gradient(135deg, #A8C77A, #6B8E3D 60%, #3D5A1F); }
|
||||
.img-3 { background: linear-gradient(135deg, #D4A574, #9C6B3F 60%, #5C3A1F); }
|
||||
.img-4 { background: linear-gradient(135deg, #E8C46A, #B8902F 60%, #7A5E1A); }
|
||||
.img-5 { background: linear-gradient(135deg, #C7A878, #8E6B3D 60%, #5A4520); }
|
||||
.img-6 { background: linear-gradient(135deg, #B5D4A1, #7AA862 60%, #4D7038); }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.page { padding: 24px 24px 60px; }
|
||||
.shelf-row { grid-template-columns: 1fr 1fr; }
|
||||
.shuffle-card { flex-direction: column; align-items: flex-start; }
|
||||
.shuffle-actions { width: 100%; justify-content: flex-end; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<nav class="nav">
|
||||
<span class="logo">ROUX</span>
|
||||
<div class="nav-links">
|
||||
<a class="active">Home</a>
|
||||
<a>Recipes</a>
|
||||
<a>Cook History</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="shuffle-card">
|
||||
<div>
|
||||
<div class="shuffle-meta">Don’t know what to cook?</div>
|
||||
<h3>Try Sheet-Pan Salmon & Asparagus</h3>
|
||||
</div>
|
||||
<div class="shuffle-actions">
|
||||
<button class="btn-ghost">↻ Shuffle</button>
|
||||
<button class="btn">Open recipe</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="shelf">
|
||||
<div class="shelf-header">
|
||||
<h3 class="shelf-title">Recently cooked</h3>
|
||||
<button class="btn-ghost">All history →</button>
|
||||
</div>
|
||||
<div class="shelf-row">
|
||||
<div class="card">
|
||||
<div class="card-image img-1"></div>
|
||||
<div class="card-title">Chicken Piccata</div>
|
||||
<div class="card-meta">May 26 · 2nd cook</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-image img-2"></div>
|
||||
<div class="card-title">Arugula Pesto Wheat Berries</div>
|
||||
<div class="card-meta">May 21</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-image img-3"></div>
|
||||
<div class="card-title">Alain Ducasse’s Gougères</div>
|
||||
<div class="card-meta">May 18</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="shelf">
|
||||
<div class="shelf-header">
|
||||
<h3 class="shelf-title">Recently added</h3>
|
||||
<button class="btn-ghost">All recipes →</button>
|
||||
</div>
|
||||
<div class="shelf-row">
|
||||
<div class="card">
|
||||
<div class="card-image img-4"></div>
|
||||
<div class="card-title">Apple Cider Vinegar Dressing</div>
|
||||
<div class="card-meta">Added May 24</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-image img-5"></div>
|
||||
<div class="card-title">Chicken Piccata</div>
|
||||
<div class="card-meta">Added May 20</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-image img-6"></div>
|
||||
<div class="card-title">Arugula Pesto Chicken</div>
|
||||
<div class="card-meta">Added May 14</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,186 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Roux — Recipe page mockup</title>
|
||||
<style>
|
||||
:root {
|
||||
--cream: #F5EBD3;
|
||||
--cream-light: #FDF6E7;
|
||||
--brown-dark: #3D2817;
|
||||
--brown-mid: #4A3525;
|
||||
--brown-soft: #6B5847;
|
||||
--brown-muted: #8A7560;
|
||||
--terracotta: #C5532E;
|
||||
--meta-strip-bg: rgba(61, 40, 23, 0.05);
|
||||
--rule: rgba(61, 40, 23, 0.18);
|
||||
--rule-soft: rgba(61, 40, 23, 0.15);
|
||||
--font-serif: Georgia, "Iowan Old Style", "Times New Roman", serif;
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; background: var(--cream); font-family: var(--font-sans); color: var(--brown-dark); }
|
||||
.page { max-width: 980px; margin: 0 auto; padding: 36px 48px 80px; }
|
||||
|
||||
.nav { display: flex; justify-content: space-between; align-items: center;
|
||||
border-bottom: 0.5px solid var(--rule); padding-bottom: 12px; margin-bottom: 28px;
|
||||
font-size: 12px; letter-spacing: 0.12em; color: var(--brown-soft); text-transform: uppercase; }
|
||||
.logo { font-family: var(--font-serif); font-size: 22px; font-weight: 500; letter-spacing: 0.08em; color: var(--brown-dark); }
|
||||
.nav-links { display: flex; gap: 28px; }
|
||||
.nav-links a { color: var(--brown-soft); text-decoration: none; cursor: pointer; }
|
||||
.nav-links a:hover { color: var(--terracotta); }
|
||||
|
||||
h1, h2, h3 { font-family: var(--font-serif); font-weight: 500; color: var(--brown-dark); margin: 0; }
|
||||
h1.recipe-title { font-size: 38px; line-height: 1.05; }
|
||||
.recipe-sub { font-size: 16px; color: var(--brown-soft); margin: 8px 0 16px; }
|
||||
|
||||
/* meta strip — the key move: last cook + quick log button right under the title */
|
||||
.meta-strip {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
background: var(--meta-strip-bg); border-radius: 10px;
|
||||
padding: 12px 18px; margin-bottom: 24px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.meta-strip .last-cook { color: var(--brown-soft); flex: 1; min-width: 0; }
|
||||
.meta-strip .last-cook strong { font-weight: 500; color: var(--brown-dark); }
|
||||
.meta-strip .last-cook .note { font-style: italic; }
|
||||
.meta-strip-actions { display: flex; gap: 14px; align-items: center; flex-shrink: 0; }
|
||||
|
||||
.btn {
|
||||
background: var(--terracotta); color: var(--cream-light);
|
||||
border: none; border-radius: 8px; padding: 9px 16px;
|
||||
font-size: 13px; font-weight: 500; cursor: pointer; font-family: inherit;
|
||||
}
|
||||
.btn:hover { filter: brightness(0.95); }
|
||||
.btn-ghost {
|
||||
background: none; border: none; color: var(--terracotta);
|
||||
font-size: 13px; cursor: pointer; padding: 0; font-family: inherit;
|
||||
}
|
||||
.btn-ghost:hover { text-decoration: underline; }
|
||||
|
||||
.hero-image {
|
||||
width: 100%; aspect-ratio: 2.2; border-radius: 10px;
|
||||
background: linear-gradient(135deg, #E8B86A, #C9893F 50%, #8A5A2A);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
/* body grid: ingredients/method on left, cook-history rail on right */
|
||||
.body-grid { display: grid; grid-template-columns: 1.5fr 1fr; gap: 40px; }
|
||||
|
||||
.section-label {
|
||||
font-size: 12px; letter-spacing: 0.12em; color: var(--brown-muted);
|
||||
text-transform: uppercase; margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.serves { font-family: var(--font-serif); font-size: 28px; margin-bottom: 22px; }
|
||||
|
||||
.ingredients { list-style: none; padding: 0; margin: 0 0 28px; font-size: 15px; line-height: 1.9; }
|
||||
.ingredients li { display: flex; gap: 14px; align-items: baseline; padding: 4px 0; border-bottom: 0.5px dashed var(--rule-soft); }
|
||||
.ingredients li:last-child { border-bottom: none; }
|
||||
.ingredients .qty { color: var(--brown-muted); min-width: 48px; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.method { font-size: 15px; line-height: 1.7; color: var(--brown-mid); }
|
||||
.method p { margin: 0 0 12px; }
|
||||
.method .step { font-weight: 500; color: var(--brown-dark); margin-right: 4px; }
|
||||
|
||||
/* cook history rail */
|
||||
.history-rail { }
|
||||
.history-rail .section-label { display: flex; justify-content: space-between; align-items: baseline; }
|
||||
.history-list { display: flex; flex-direction: column; gap: 0; }
|
||||
.history-entry { padding: 12px 0; border-bottom: 0.5px solid var(--rule-soft); }
|
||||
.history-entry:last-child { border-bottom: none; }
|
||||
.history-date { font-family: var(--font-serif); font-size: 15px; font-weight: 500; color: var(--brown-dark); margin-bottom: 2px; }
|
||||
.history-note { font-size: 13px; color: var(--brown-soft); line-height: 1.5; }
|
||||
.history-note.empty { color: var(--brown-muted); font-style: italic; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.page { padding: 24px 24px 60px; }
|
||||
.meta-strip { flex-direction: column; align-items: flex-start; }
|
||||
.meta-strip-actions { width: 100%; justify-content: flex-end; }
|
||||
.body-grid { grid-template-columns: 1fr; gap: 32px; }
|
||||
h1.recipe-title { font-size: 30px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<nav class="nav">
|
||||
<span class="logo">ROUX</span>
|
||||
<div class="nav-links">
|
||||
<a>Recipes</a>
|
||||
<a>Cook History</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<h1 class="recipe-title">Chicken Piccata</h1>
|
||||
<p class="recipe-sub">With buttery angel hair pasta & dressed greens with parmesan</p>
|
||||
|
||||
<div class="meta-strip">
|
||||
<div class="last-cook">
|
||||
<strong>Last cooked May 26</strong> — <span class="note">“Pretty good — lots of extra pasta.”</span>
|
||||
</div>
|
||||
<div class="meta-strip-actions">
|
||||
<button class="btn-ghost">↗ Original</button>
|
||||
<button class="btn">+ Log meal</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-image" role="img" aria-label="Chicken Piccata photo"></div>
|
||||
|
||||
<div class="body-grid">
|
||||
|
||||
<div>
|
||||
<div class="section-label">Serves</div>
|
||||
<div class="serves">2</div>
|
||||
|
||||
<div class="section-label">Ingredients</div>
|
||||
<ul class="ingredients">
|
||||
<li><span class="qty">—</span><span>water</span></li>
|
||||
<li><span class="qty">2</span><span>chicken breasts</span></li>
|
||||
<li><span class="qty">1</span><span>lemon</span></li>
|
||||
<li><span class="qty">¼ c</span><span>parsley</span></li>
|
||||
<li><span class="qty">1 T</span><span>capers</span></li>
|
||||
<li><span class="qty">2 T</span><span>olive oil</span></li>
|
||||
<li><span class="qty">2 T</span><span>butter</span></li>
|
||||
</ul>
|
||||
|
||||
<div class="section-label">Method</div>
|
||||
<div class="method">
|
||||
<p><span class="step">1.</span> Slice chicken breasts horizontally, season, and dredge in flour.</p>
|
||||
<p><span class="step">2.</span> Sear in olive oil over medium-high until golden, about 3 min per side. Set aside.</p>
|
||||
<p><span class="step">3.</span> Deglaze the pan with lemon juice and a splash of water; add butter and capers and simmer until glossy.</p>
|
||||
<p><span class="step">4.</span> Return chicken to the pan, spoon sauce over, and rest 2 min before plating over angel hair.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="history-rail">
|
||||
<div class="section-label">
|
||||
<span>Cook history</span>
|
||||
<button class="btn-ghost">+ Add</button>
|
||||
</div>
|
||||
<div class="history-list">
|
||||
<div class="history-entry">
|
||||
<div class="history-date">May 26, 2026</div>
|
||||
<div class="history-note">Pretty good — lots of extra pasta.</div>
|
||||
</div>
|
||||
<div class="history-entry">
|
||||
<div class="history-date">May 26, 2026</div>
|
||||
<div class="history-note empty">No notes</div>
|
||||
</div>
|
||||
<div class="history-entry">
|
||||
<div class="history-date">Apr 12, 2026</div>
|
||||
<div class="history-note">Doubled the capers — much better.</div>
|
||||
</div>
|
||||
<div class="history-entry">
|
||||
<div class="history-date">Feb 03, 2026</div>
|
||||
<div class="history-note">First try. Sauce broke a little.</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+246
-256
@@ -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"
|
||||
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_ "roux-course" $ H.toHtml c
|
||||
Just c -> H.p ! A.class_ "rp-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
|
||||
-- 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_ "roux-desc" $ H.toHtml d
|
||||
Just d -> H.p ! A.class_ "rp-sub" $ H.toHtml d
|
||||
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
|
||||
-- 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.div ! A.class_ "note" $ do
|
||||
H.h2 "Source"
|
||||
H.p $ H.toHtml url
|
||||
Just url -> H.a ! A.class_ "rp-btn-ghost" ! A.href (H.toValue url) ! A.target "_blank" $ "\x2197 Original"
|
||||
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
|
||||
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