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>
|
||||||
+254
-264
@@ -23,9 +23,8 @@ module Roux.Html (
|
|||||||
import Control.Monad (unless)
|
import Control.Monad (unless)
|
||||||
import Data.ByteString.Lazy (ByteString)
|
import Data.ByteString.Lazy (ByteString)
|
||||||
import qualified Data.ByteString.Lazy as LB
|
import qualified Data.ByteString.Lazy as LB
|
||||||
import Data.Function ((&))
|
|
||||||
import qualified Data.List.NonEmpty as NE
|
import qualified Data.List.NonEmpty as NE
|
||||||
import Data.Maybe (catMaybes, fromMaybe)
|
import Data.Maybe (fromMaybe)
|
||||||
import Data.Ratio (denominator, numerator)
|
import Data.Ratio (denominator, numerator)
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
@@ -61,10 +60,7 @@ newtype ImportError = ImportError Text
|
|||||||
-- Helpers used throughout
|
-- 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 display.
|
||||||
| Format a quantity for natural display (e.g. @4 cups@, @1/2 tsp@).
|
| Format a quantity for natural display (e.g. @4 cups@, @1/2 tsp@).
|
||||||
@@ -652,7 +648,7 @@ cookLogJs =
|
|||||||
T.unlines
|
T.unlines
|
||||||
[ "(function(){"
|
[ "(function(){"
|
||||||
, "'use strict';"
|
, "'use strict';"
|
||||||
, "var btn = document.getElementById('roux-record-btn');"
|
, "var btns = document.querySelectorAll('.roux-record-trigger');"
|
||||||
, "var modal = document.getElementById('roux-record-modal');"
|
, "var modal = document.getElementById('roux-record-modal');"
|
||||||
, "var backdrop = document.getElementById('roux-modal-backdrop');"
|
, "var backdrop = document.getElementById('roux-modal-backdrop');"
|
||||||
, "var cancel = document.getElementById('roux-modal-cancel');"
|
, "var cancel = document.getElementById('roux-modal-cancel');"
|
||||||
@@ -660,7 +656,7 @@ cookLogJs =
|
|||||||
, "var dateInput = document.getElementById('roux-record-date');"
|
, "var dateInput = document.getElementById('roux-record-date');"
|
||||||
, "var commentInput = document.getElementById('roux-record-comment');"
|
, "var commentInput = document.getElementById('roux-record-comment');"
|
||||||
, "var errorEl = document.getElementById('roux-record-error');"
|
, "var errorEl = document.getElementById('roux-record-error');"
|
||||||
, "if (!btn || !modal || !form) return;"
|
, "if (!btns.length || !modal || !form) return;"
|
||||||
, ""
|
, ""
|
||||||
, "function showModal() {"
|
, "function showModal() {"
|
||||||
, " dateInput.value = new Date().toISOString().slice(0,10);"
|
, " dateInput.value = new Date().toISOString().slice(0,10);"
|
||||||
@@ -674,7 +670,7 @@ cookLogJs =
|
|||||||
, " modal.style.display = 'none';"
|
, " modal.style.display = 'none';"
|
||||||
, "}"
|
, "}"
|
||||||
, ""
|
, ""
|
||||||
, "btn.addEventListener('click', showModal);"
|
, "btns.forEach(function(b) { b.addEventListener('click', showModal); });"
|
||||||
, "if (cancel) cancel.addEventListener('click', hideModal);"
|
, "if (cancel) cancel.addEventListener('click', hideModal);"
|
||||||
, "if (backdrop) backdrop.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 titleEditJs
|
||||||
H.script ! A.type_ "text/javascript" $ H.preEscapedText cookLogJs
|
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".
|
-- | Format a Day as "Mon DD" e.g. "Mar 15".
|
||||||
formatDay :: Day -> Text
|
formatDay :: Day -> Text
|
||||||
formatDay = T.pack . Time.formatTime Time.defaultTimeLocale "%b %e"
|
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 :: FilePath -> Recipe -> [CookLog.CookEntry] -> Html
|
||||||
renderRecipe filename recipe entries = do
|
renderRecipe filename recipe entries = do
|
||||||
H.nav ! A.class_ "roux-navbar" $ do
|
H.style $ H.toHtml recipePageStyles
|
||||||
H.ul $ H.li $ H.a ! A.class_ "roux-logo" ! A.href "/" $ "Roux"
|
H.div ! A.class_ "rp-page" $ do
|
||||||
H.ul $ H.li $ H.a ! A.href "/recipes" $ "Recipes"
|
-- Nav (matching landing page style)
|
||||||
let meta = recipeMetadata recipe
|
H.nav ! A.class_ "rp-nav" $ do
|
||||||
course = metaCourse meta
|
H.a ! A.class_ "rp-logo" ! A.href "/" $ "ROUX"
|
||||||
desc = metaDescription meta
|
H.div ! A.class_ "rp-nav-links" $ do
|
||||||
case course of
|
H.a ! A.href "/recipes" $ "Recipes"
|
||||||
Just c -> H.p ! A.class_ "roux-course" $ H.toHtml c
|
H.a ! A.href "/cook-history" $ "Cook History"
|
||||||
Nothing -> pure ()
|
let meta = recipeMetadata recipe
|
||||||
H.div ! A.style "display: flex; justify-content: space-between; align-items: baseline; gap: 1rem;" $ do
|
course = metaCourse meta
|
||||||
H.h1 ! A.class_ "roux-recipe-title" $ H.toHtml (titleText recipe)
|
desc = metaDescription meta
|
||||||
H.div ! A.style "text-align: right;" $ do
|
-- Course label
|
||||||
H.div ! A.style "display: flex; gap: 0.75rem; align-items: center; justify-content: flex-end;" $ do
|
case course of
|
||||||
case metaSource meta of
|
Just c -> H.p ! A.class_ "rp-course" $ H.toHtml c
|
||||||
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
|
|
||||||
Nothing -> pure ()
|
Nothing -> pure ()
|
||||||
case metaImage meta of
|
-- Title (inline-editable via existing JS)
|
||||||
Just img -> do
|
H.h1 ! A.class_ "roux-recipe-title rp-title" $ H.toHtml (titleText recipe)
|
||||||
H.div ! A.class_ "roux-image-wrap" $ do
|
-- Subtitle (from description)
|
||||||
H.img ! A.src (H.toValue img) ! A.alt (H.toValue (titleText recipe))
|
case desc of
|
||||||
H.label ! A.class_ "roux-image-overlay" ! A.for "roux-image-input" $ "Replace"
|
Just d -> H.p ! A.class_ "rp-sub" $ H.toHtml d
|
||||||
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 -> pure ()
|
||||||
Nothing -> do
|
-- Meta strip: last cook + source + log meal
|
||||||
H.div ! A.class_ "roux-image-wrap roux-image-empty" $ do
|
H.div ! A.class_ "rp-meta-strip" $ do
|
||||||
H.label ! A.class_ "roux-image-placeholder" ! A.for "roux-image-input" $ "Upload photo"
|
H.div ! A.class_ "rp-meta-strip-last" $ rpLastCookInfo entries
|
||||||
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;"
|
H.div ! A.class_ "rp-meta-strip-actions" $ do
|
||||||
renderMetaBar meta
|
case metaSource meta of
|
||||||
let sections = NE.toList (recipeSections recipe)
|
Just url -> H.a ! A.class_ "rp-btn-ghost" ! A.href (H.toValue url) ! A.target "_blank" $ "\x2197 Original"
|
||||||
notes = collectNotes recipe
|
Nothing -> pure ()
|
||||||
H.div ! A.class_ "roux-grid" $ do
|
H.button ! A.class_ "rp-btn roux-record-trigger" ! A.id "roux-record-btn" ! H.dataAttribute "filename" (H.toValue (T.pack filename)) $ "+ Log meal"
|
||||||
-- Ingredients column
|
-- Hero image
|
||||||
H.div $ do
|
rpHeroImage meta filename
|
||||||
H.h2 "Ingredients"
|
-- Body grid
|
||||||
mapM_ renderIngredientGroup sections
|
H.div ! A.class_ "rp-body-grid" $ do
|
||||||
-- Method column
|
-- Left column: servings, ingredients, method
|
||||||
H.div $ do
|
H.div $ do
|
||||||
H.h2 "Method"
|
case metaServings meta of
|
||||||
renderMethodSections sections
|
Just s -> do
|
||||||
-- Marginalia column
|
H.div ! A.class_ "rp-section-label" $ "Serves"
|
||||||
H.div ! A.class_ "roux-marginalia" $ do
|
H.div ! A.class_ "rp-serves" $ H.toHtml (showServings s)
|
||||||
case metaSource meta of
|
Nothing -> pure ()
|
||||||
Just url -> H.div ! A.class_ "note" $ do
|
let sections = NE.toList (recipeSections recipe)
|
||||||
H.h2 "Source"
|
H.div ! A.class_ "rp-section-label" $ "Ingredients"
|
||||||
H.p $ H.toHtml url
|
rpIngredientList sections
|
||||||
Nothing -> pure ()
|
H.div ! A.class_ "rp-section-label" $ "Method"
|
||||||
let recipeTags = metaTags meta
|
rpMethodList sections
|
||||||
unless (null recipeTags) $ do
|
-- Right column: cook history rail
|
||||||
H.div ! A.class_ "note" $ do
|
H.aside $ do
|
||||||
H.h2 "Tags"
|
H.div ! A.class_ "rp-section-label" $ do
|
||||||
H.p $ H.toHtml (T.intercalate " · " recipeTags)
|
H.span "Cook history"
|
||||||
renderCookLog filename entries
|
H.button ! A.class_ "rp-btn-ghost roux-record-trigger" ! H.dataAttribute "filename" (H.toValue (T.pack filename)) $ "+ Add"
|
||||||
unless (null notes) $ do
|
rpHistoryList entries
|
||||||
H.h2 "Notes"
|
-- Tags
|
||||||
mapM_ renderMarginalNote notes
|
let tags = metaTags meta
|
||||||
-- Record meal modal
|
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.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-backdrop" ! A.id "roux-modal-backdrop" $ ""
|
||||||
H.div ! A.class_ "roux-modal-dialog" $ do
|
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_ "button" ! A.class_ "roux-modal-cancel" ! A.id "roux-modal-cancel" $ "Cancel"
|
||||||
H.button ! A.type_ "submit" ! A.class_ "roux-modal-confirm" $ "Log it"
|
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.
|
-- | Extract display title from recipe metadata or fallback.
|
||||||
titleText :: Recipe -> Text
|
titleText :: Recipe -> Text
|
||||||
titleText recipe = fromMaybe "Untitled Recipe" (metaTitle (recipeMetadata recipe))
|
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.
|
-- | Format servings info.
|
||||||
showServings :: (Int, Maybe Text) -> Text
|
showServings :: (Int, Maybe Text) -> Text
|
||||||
showServings (n, Nothing) = T.pack (show n)
|
showServings (n, Nothing) = T.pack (show n)
|
||||||
@@ -1084,104 +1202,6 @@ durationToSeconds d =
|
|||||||
toDouble :: Rational -> Double
|
toDouble :: Rational -> Double
|
||||||
toDouble r = fromIntegral (numerator r) / fromIntegral (denominator r)
|
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)
|
-- Step items (inline rendering within a step)
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
@@ -1218,36 +1238,6 @@ renderStepItem (StepComment t) =
|
|||||||
renderStepItem StepBreak =
|
renderStepItem StepBreak =
|
||||||
H.br
|
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
|
-- URL encoding
|
||||||
|
|||||||
Reference in New Issue
Block a user