Files
roux/src/Roux/Html.hs
T
jbrechtel 7b64f9576b
Build and Deploy / build-and-deploy (push) Successful in 12m59s
Fix 'btn is not defined' JS error in Record meal modal
When the trigger JS was updated to use querySelectorAll('.roux-record-trigger')
for dual-button support, the variable was renamed from 'btn' to 'btns' but the
submit handler still referenced 'btn'. This commit:

- Adds activeBtn tracking: the button that opened the modal is stored in
  activeBtn via 'this' in the click handler
- Uses activeBtn.dataset.filename in the submit handler (with null guard)
- Removes the stale btn reference
2026-05-26 07:44:50 -04:00

1474 lines
78 KiB
Haskell

{-# OPTIONS_GHC -Wno-name-shadowing #-}
{- | HTML rendering for the Roux web interface.
Uses blaze-html directly. Styling via Pico CSS.
Blaze-html re-exports many common names (title, meta, step, a, b, …)
so name-shadowing is unavoidable with local bindings.
-}
module Roux.Html (
SortMode (..),
ImportError (..),
importPage,
importResultPage,
indexPage,
recipePage,
cookHistoryPage,
landingPage,
urlEncode,
urlDecode,
) where
import Control.Monad (unless)
import Data.ByteString.Lazy (ByteString)
import qualified Data.ByteString.Lazy as LB
import qualified Data.List.NonEmpty as NE
import Data.Maybe (fromMaybe)
import Data.Ratio (denominator, numerator)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (decodeUtf8)
import qualified Fleece.Aeson as Fleece
import qualified Fleece.Core as FC
import qualified Text.Blaze.Html.Renderer.Utf8 as R
import Text.Blaze.Html5 as H
import Text.Blaze.Html5.Attributes as A
import Data.CookLang
import Data.Time.Calendar (Day)
import qualified Data.Time.Format as Time
import qualified Roux.CookLog as CookLog
import qualified Roux.RecipeIndex as Idx
-- ---------------------------------------------------------------------------
-- Sort mode for the index page
-- ---------------------------------------------------------------------------
-- | How to group or sort recipes on the index page.
data SortMode
= AlphaSort
| TagSort
| CourseSort
deriving stock (Eq, Show)
-- | Error type for the import pipeline.
newtype ImportError = ImportError Text
deriving stock (Eq, Show)
-- ---------------------------------------------------------------------------
-- Helpers used throughout
-- ---------------------------------------------------------------------------
{- | Format a quantity for display.
| Format a quantity for natural display (e.g. @4 cups@, @1/2 tsp@).
-}
showQuantity :: Quantity -> Text
showQuantity q =
let amt = quantityAmount q
amount = case denominator amt of
1 -> T.pack (show (numerator amt))
_ -> formatFraction amt
prefix = if quantityFixed q then "=" else ""
unit = maybe "" (\u -> " " <> pluralize amt u) (quantityUnit q)
in prefix <> amount <> unit
-- | Format a rational as a nice fraction string (e.g. @1/2@, @1 1/2@).
formatFraction :: Rational -> Text
formatFraction r =
let w = numerator r `Prelude.div` denominator r
rem = r - toRational w
in case w of
0 -> simpleFrac rem
_ -> T.pack (show w) <> " " <> simpleFrac rem
where
simpleFrac f =
let n = numerator f
d = denominator f
in T.pack (show n <> "/" <> show d)
-- | Pluralize a unit name based on the amount.
pluralize :: Rational -> Text -> Text
pluralize amt unit
| amt == 1 = unit
| otherwise = case T.toLower unit of
-- Units that stay the same (abbreviations, mass, volume)
"g" -> "g"
"kg" -> "kg"
"ml" -> "ml"
"l" -> "l"
"oz" -> "oz"
"lb" -> "lbs"
"tbsp" -> "tbsp"
"tsp" -> "tsp"
"cup" -> "cups"
"tablespoon" -> "tablespoons"
"teaspoon" -> "teaspoons"
"ounce" -> "ounces"
"pound" -> "pounds"
"pinch" -> "pinches"
"clove" -> "cloves"
"item" -> "items"
"piece" -> "pieces"
"can" -> "cans"
"package" -> "packages"
"bag" -> "bags"
"bunch" -> "bunches"
"sprig" -> "sprigs"
"leaf" -> "leaves"
"slice" -> "slices"
"minute" -> "minutes"
"hour" -> "hours"
"second" -> "seconds"
_ -> unit
-- ---------------------------------------------------------------------------
-- Page shell
-- ---------------------------------------------------------------------------
-- | Wrap content in a full HTML5 page with custom styling.
page :: Text -> Html -> ByteString
page title content =
R.renderHtml $
H.docTypeHtml $ do
H.head $ do
H.meta ! A.charset "utf-8"
H.meta ! A.name "viewport" ! A.content "width=device-width, initial-scale=1"
H.title (H.toHtml title)
H.link ! A.rel "stylesheet" ! A.href "https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
H.style $
H.toHtml $
T.unlines
[ "@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500;9..144,600&family=Quicksand:wght@300..700&display=swap');"
, ""
, ":root { --roux-bg: #F5EFE0; --roux-text: #3D2C1E; --roux-accent: #B85C38; --roux-ochre: #BF9428; --roux-sage: #7A9A7A; --roux-paper: #F5EFE0; --roux-muted: #6B5D4E; }"
, ".fraunces { font-family: \"Fraunces\", serif; font-optical-sizing: auto; font-style: normal; }"
, ".quicksand { font-family: \"Quicksand\", sans-serif; font-optical-sizing: auto; font-style: normal; }"
, ""
, "body { background: var(--roux-bg); color: var(--roux-text); font-family: \"Quicksand\", sans-serif; }"
, "main.container { padding-top: 1.5rem; max-width: 1120px; }"
, "h1, h3, .roux-heading { font-family: \"Fraunces\", serif; font-optical-sizing: auto; }"
, "h1 { font-size: 2rem; font-weight: 500; margin: 0 0 0.5rem; line-height: 1.15; color: var(--roux-text); letter-spacing: -0.01em; }"
, "h2, .container h2 { font-family: \"Fraunces\", serif; font-size: 0.75rem; font-weight: 600; margin: 0 0 1rem; letter-spacing: 0.04em; text-transform: uppercase; color: var(--roux-text); opacity: 0.6; }"
, "h3 { font-size: 1rem; font-weight: 500; margin: 1.5rem 0 0.5rem; color: var(--roux-text); text-transform: uppercase; opacity: 0.75; }"
, "a { color: var(--roux-text); text-decoration: none; transition: color 0.15s; }"
, "a:hover { color: var(--roux-accent); }"
, "a[role=button] { --pico-color: var(--roux-accent); }"
, ""
, "/* Navbar */"
, "nav.roux-navbar { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 0; margin-bottom: 1rem; border-bottom: 1px solid rgba(61, 44, 30, 0.1); }"
, ".roux-navbar .roux-logo { font-family: \"Fraunces\", serif; font-size: 1.1rem; font-weight: 500; color: var(--roux-text); text-decoration: none; }"
, ".roux-navbar .roux-logo:hover { color: var(--roux-text); }"
, ".roux-navbar ul { display: flex; gap: 1rem; list-style: none; margin: 0; padding: 0; }"
, ".roux-navbar li { margin: 0; padding: 0; list-style: none; }"
, ".roux-navbar a { font-size: 0.8rem; letter-spacing: 0.04em; text-transform: uppercase; }"
, ".roux-navbar a.active { color: var(--roux-accent); font-weight: 500; }"
, ""
, "/* Search input */"
, "input[type=search] { background: var(--roux-bg); color: var(--roux-text); border-color: rgba(61, 44, 30, 0.2); }"
, "input[type=search]:focus { border-color: var(--roux-accent); box-shadow: 0 0 0 1px var(--roux-accent); }"
, ""
, "/* Import form inputs */"
, ".roux-import-input { background: var(--roux-bg); color: var(--roux-text); border-color: rgba(61, 44, 30, 0.2); width: 100%; margin-bottom: 1rem; }"
, ".roux-import-input:focus { border-color: var(--roux-accent); box-shadow: 0 0 0 1px var(--roux-accent); }"
, "textarea.roux-import-input { font-family: monospace; font-size: 0.85rem; resize: vertical; }"
, "/* Recipe list on index page */"
, ".roux-recipes { list-style: none; padding: 0; }"
, ".roux-recipes li { display: flex; align-items: flex-start; gap: 0.75rem; padding: 0.4rem 0; }"
, ".roux-recipes li a { font-size: 1rem; display: block; color: var(--roux-text); }"
, ".roux-recipes li a:hover { color: var(--roux-accent); }"
, ".roux-index-thumb { width: 48px; height: 48px; object-fit: cover; border-radius: 4px; flex-shrink: 0; margin-top: 0.1rem; }"
, ".roux-index-entry { min-width: 0; }"
, ".roux-letter-divider { font-family: \"Fraunces\", serif; font-size: 1.1rem; font-weight: 500; color: var(--roux-text); margin: 1.2rem 0 0.25rem; padding-bottom: 0.2rem; border-bottom: 0.5px solid rgba(61, 44, 30, 0.08); }"
, ".roux-recipe-meta { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-top: 0.1rem; }"
, ".roux-recipe-meta .r-tag { font-size: 0.65rem; color: rgba(61, 44, 30, 0.5); letter-spacing: 0.02em; }"
, ".roux-recipes .r-course { font-size: 0.65rem; color: var(--roux-accent); opacity: 0.6; letter-spacing: 0.03em; text-transform: uppercase; }"
, ""
, "/* Recipe page */"
, ".roux-meta { display: flex; gap: 1.5rem; margin-bottom: 1.5rem; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(61, 44, 30, 0.08); }"
, ".roux-meta-item { text-align: center; }"
, ".roux-meta-item .label { font-size: 0.65rem; color: rgba(61, 44, 30, 0.5); margin: 0 0 2px; letter-spacing: 0.04em; text-transform: uppercase; font-weight: 500; }"
, ".roux-meta-item .value { font-size: 0.95rem; font-weight: 500; margin: 0; color: var(--roux-text); }"
, ""
, ".roux-grid { display: grid; grid-template-columns: 240px 1fr 180px; gap: 2rem; }"
, "@media (max-width: 1024px) { .roux-grid { grid-template-columns: 240px 1fr; } }"
, "@media (max-width: 768px) { .roux-grid { grid-template-columns: 1fr; } }"
, ""
, ".roux-marginalia { font-size: 0.8rem; line-height: 1.6; color: rgba(61, 44, 30, 0.6); }"
, ".roux-marginalia h2 { font-size: 0.65rem; letter-spacing: 0.03em; text-transform: uppercase; color: rgba(61, 44, 30, 0.4); margin: 0 0 0.75rem; }"
, ".roux-marginalia .note { padding: 0.75rem 0; border-bottom: 0.5px solid rgba(61, 44, 30, 0.06); }"
, ".roux-marginalia .note:last-child { border-bottom: none; }"
, ".roux-marginalia .ornament { color: var(--roux-accent); font-size: 1rem; margin-right: 0.3rem; }"
, "@media (max-width: 1024px) { .roux-marginalia { display: none; } }"
, ""
, ".roux-source-link { font-size: 0.8rem; color: var(--roux-text); opacity: 0.5; white-space: nowrap; flex-shrink: 0; transition: opacity 0.15s; }"
, ".roux-source-link:hover { opacity: 1; color: var(--roux-accent); }"
, ""
, ".roux-subsection { font-size: 0.6rem; color: rgba(61, 44, 30, 0.4); margin: 0 0 6px; letter-spacing: 0.05em; text-transform: uppercase; font-weight: 500; }"
, ""
, ".roux-ingredient-row { display: flex; align-items: flex-start; gap: 8px; padding: 3px 0; }"
, ".roux-ingredient-row input { margin-top: 5px; appearance: none; -webkit-appearance: none; width: 15px; height: 15px; border: 1.5px solid rgba(61, 44, 30, 0.25); border-radius: 2px; background: transparent; cursor: pointer; flex-shrink: 0; position: relative; transform: rotate(0.5deg); transition: border-color 0.15s; }"
, ".roux-ingredient-row input:checked { border-color: rgba(61, 44, 30, 0.5); background: transparent; }"
, ".roux-ingredient-row input:checked::after { content: '✓'; position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%) rotate(-0.5deg); font-size: 11px; font-weight: 400; color: var(--roux-text); opacity: 0.5; line-height: 1; }"
, ".roux-ingredient-row .name { font-size: 0.9rem; line-height: 1.5; color: var(--roux-text); transition: opacity 0.15s; }"
, ".roux-ingredient-row .qty { font-size: 0.8rem; color: var(--roux-text); opacity: 0.55; margin-right: 4px; }"
, ".roux-ingredient-row:has(input:checked) .name { opacity: 0.4; }"
, ".roux-ingredient-row .r-plain { font-size: 0.8rem; color: rgba(61, 44, 30, 0.55); font-style: italic; }"
, ""
, ".roux-step { display: flex; align-items: flex-start; gap: 12px; padding: 8px 0; transition: opacity 0.15s; }"
, ".roux-step input { margin-top: 6px; appearance: none; -webkit-appearance: none; width: 15px; height: 15px; border: 1.5px solid rgba(61, 44, 30, 0.25); border-radius: 2px; background: transparent; cursor: pointer; flex-shrink: 0; position: relative; transform: rotate(-0.3deg); transition: border-color 0.15s; }"
, ".roux-step input:checked { border-color: rgba(61, 44, 30, 0.5); background: transparent; }"
, ".roux-step input:checked::after { content: '✓'; position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%) rotate(0.3deg); font-size: 11px; font-weight: 400; color: var(--roux-text); opacity: 0.5; line-height: 1; }"
, ".roux-step .step-label { font-size: 0.7rem; color: var(--roux-accent); font-weight: 500; margin: 0 0 2px; letter-spacing: 0.01em; }"
, ".roux-step .step-text { font-size: 0.9rem; line-height: 1.65; margin: 0; color: var(--roux-text); }"
, ".roux-step:has(input:checked) .step-text { opacity: 0.4; }"
, ""
, ".roux-course { font-size: 0.6rem; color: var(--roux-accent); opacity: 0.65; margin: 0 0 0.15rem; letter-spacing: 0.04em; text-transform: uppercase; font-weight: 500; }"
, ""
, ".roux-record-btn { font-size: 0.8rem; padding: 0.4rem 0.8rem; background: var(--roux-accent); color: #fff; border: none; border-radius: 4px; cursor: pointer; white-space: nowrap; font-family: \"Quicksand\", sans-serif; font-weight: 500; transition: opacity 0.15s; }"
, ".roux-record-btn:hover { opacity: 0.85; }"
, ""
, "/* Record meal modal */"
, ".roux-modal-backdrop { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.3); z-index: 999; }"
, ".roux-modal-dialog { position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%); background: var(--roux-paper); border-radius: 8px; padding: 1.5rem; z-index: 1000; min-width: 320px; box-shadow: 0 4px 24px rgba(61,44,30,0.2); }"
, ".roux-modal-dialog h3 { margin: 0 0 1rem; font-size: 1.1rem; }"
, ".roux-modal-dialog label { display: block; font-size: 0.8rem; font-weight: 500; margin-bottom: 0.3rem; color: var(--roux-text); opacity: 0.7; }"
, ".roux-modal-dialog input, .roux-modal-dialog textarea { width: 100%; background: var(--roux-bg); border: 1px solid rgba(61,44,30,0.2); border-radius: 4px; padding: 0.5rem; font-family: \"Quicksand\", sans-serif; font-size: 0.9rem; color: var(--roux-text); margin-bottom: 0.75rem; }"
, ".roux-modal-dialog textarea { min-height: 80px; resize: vertical; }"
, ".roux-modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 0.5rem; }"
, ".roux-modal-cancel { font-size: 0.8rem; padding: 0.4rem 0.8rem; background: transparent; border: 1px solid rgba(61,44,30,0.2); border-radius: 4px; cursor: pointer; color: var(--roux-text); font-family: \"Quicksand\", sans-serif; transition: opacity 0.15s; }"
, ".roux-modal-cancel:hover { opacity: 0.7; }"
, ".roux-modal-confirm { font-size: 0.8rem; padding: 0.4rem 0.8rem; background: var(--roux-accent); color: #fff; border: none; border-radius: 4px; cursor: pointer; font-family: \"Quicksand\", sans-serif; font-weight: 500; transition: opacity 0.15s; }"
, ".roux-modal-confirm:hover { opacity: 0.85; }"
, ".roux-desc { font-size: 0.85rem; line-height: 1.6; color: var(--roux-text); margin: 0; max-width: 640px; opacity: 0.85; }"
, ".roux-desc-row { display: flex; gap: 2rem; align-items: flex-start; margin-bottom: 1.5rem; }"
, ".roux-desc-row .roux-desc { flex: 1; }"
, ".roux-desc-row img { max-width: 280px; height: auto; border-radius: 6px; box-shadow: 0 2px 8px rgba(61, 44, 30, 0.1); }"
, ".roux-image-wrap { position: relative; display: inline-block; }"
, ".roux-image-wrap img { display: block; }"
, ".roux-image-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 6px 6px; }"
, ".roux-image-wrap:hover .roux-image-overlay { opacity: 1; }"
, ".roux-image-empty { width: 280px; height: 180px; border: 2px dashed rgba(61,44,30,0.15); border-radius: 6px; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: border-color 0.15s; }"
, ".roux-image-empty:hover { border-color: var(--roux-accent); }"
, ".roux-image-placeholder { font-size: 0.8rem; color: rgba(61,44,30,0.35); cursor: pointer; }"
, ".roux-image-empty:hover .roux-image-placeholder { color: var(--roux-accent); }"
, "@media (max-width: 768px) { .roux-desc-row { flex-direction: column; } .roux-desc-row img { max-width: 100%; } }"
, ""
, ".roux-ingredient-tag { border-bottom: 1.5px solid rgba(191, 148, 40, 0.35); }"
, ".roux-cookware-tag, .roux-timer-tag { opacity: 0.85; }"
, ".roux-ingredient-tag .qty { font-size: 0.8rem; color: var(--roux-text); opacity: 0.55; }"
, ".roux-comment-tag { opacity: 0.5; font-style: italic; font-size: 0.85em; }"
, ".tag { display: inline-block; border: 1px solid rgba(122, 154, 122, 0.35); padding: 0.1rem 0.5rem; margin: 0.1rem; border-radius: 4px; font-size: 0.7rem; color: var(--roux-sage); background: transparent; }"
, ".r-section-header { display: block; font-family: \"Fraunces\", serif; font-size: 0.85rem; font-weight: 500; color: var(--roux-text); opacity: 0.5; margin: 1rem 0 0.5rem; letter-spacing: 0.01em; border-top: 0.5px solid rgba(61, 44, 30, 0.06); padding-top: 0.75rem; }"
, ".r-section-header:first-child { border-top: none; margin-top: 0; padding-top: 0; }"
, ""
, "/* Interactive timer */"
, ".roux-timer-tag { cursor: pointer; border-bottom: 1px dashed rgba(61, 44, 30, 0.25); padding: 0 1px; }"
, ".roux-timer-tag:hover { background: rgba(61, 44, 30, 0.04); border-radius: 2px; }"
, ".roux-timer-tag .roux-timer-controls { display: none; }"
, ".roux-timer-tag.running .roux-timer-label { display: none; }"
, ".roux-timer-tag.running .roux-timer-controls { display: inline-flex; align-items: center; gap: 4px; }"
, ".roux-timer-display { font-variant-numeric: tabular-nums; font-weight: 500; min-width: 3.5em; text-align: center; }"
, ".roux-timer-display.done { color: var(--roux-accent); }"
, ".roux-timer-display.pulse { animation: roux-timer-pulse 0.5s ease-in-out 3; }"
, "@keyframes roux-timer-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }"
, ".roux-timer-reset, .roux-timer-close { background: none; border: none; cursor: pointer; font-size: 0.75rem; padding: 0 2px; line-height: 1; color: rgba(61, 44, 30, 0.4); transition: color 0.15s; }"
, ".roux-timer-reset:hover, .roux-timer-close:hover { color: var(--roux-accent); }"
, ""
, ".roux-recipe-title { cursor: pointer; position: relative; }"
, ".roux-recipe-title:hover::after { content: '\\270E'; font-size: 0.7rem; opacity: 0.3; margin-left: 0.5rem; vertical-align: super; }"
, ".roux-recipe-title.roux-editing { cursor: text; border-bottom: 1px dashed rgba(184, 92, 56, 0.4); outline: none; }"
, ".roux-recipe-title.roux-title-error { animation: roux-shake 0.3s ease-in-out; }"
, "@keyframes roux-shake { 0%,100% { transform: translateX(0); } 25% { transform: translateX(-4px); } 75% { transform: translateX(4px); } }"
]
H.body $ H.main ! A.class_ "container" $ content
-- ---------------------------------------------------------------------------
-- Index page
-- ---------------------------------------------------------------------------
-- | Inline JavaScript for client-side search + sort rendering.
searchJs :: Text
searchJs =
T.unlines
[ "(function(){"
, "'use strict';"
, ""
, "const recipes = JSON.parse(document.getElementById('roux-recipe-data').textContent);"
, "const errors = JSON.parse(document.getElementById('roux-errors-data').textContent);"
, "const listEl = document.getElementById('roux-recipe-list');"
, "const searchEl = document.getElementById('roux-search');"
, ""
, "function getSortMode() {"
, " const h = location.hash.slice(1);"
, " if (h === 'tags') return 'tags';"
, " if (h === 'course') return 'course';"
, " return 'alpha';"
, "}"
, ""
, "function render() {"
, " const query = searchEl.value.toLowerCase().trim();"
, " const mode = getSortMode();"
, " let filtered = recipes;"
, " if (query) {"
, " filtered = recipes.filter(r =>"
, " r.title.toLowerCase().includes(query) ||"
, " r.filename.toLowerCase().includes(query)"
, " );"
, " }"
, " let html = '';"
, " if (query || mode === 'alpha') {"
, " const sorted = [...filtered].sort((a, b) => a.title.localeCompare(b.title));"
, " html = renderFlatList(sorted);"
, " } else if (mode === 'tags') {"
, " html = renderByTags(filtered);"
, " } else if (mode === 'course') {"
, " html = renderByCourse(filtered);"
, " }"
, " if (errors.length > 0) {"
, " html += '<h3>Unparseable recipes</h3><ul>';"
, " errors.forEach(e => { html += '<li>' + escapeHtml(e.title) + '</li>'; });"
, " html += '</ul>';"
, " }"
, " listEl.innerHTML = html;"
, "}"
, ""
, "function renderRecipeEntry(r) {"
, " let meta = '';"
, " if (r.tags && r.tags.length > 0) {"
, " meta += '<span class=\\\'r-tag\\\'>' + r.tags.map(escapeHtml).join(' &middot; ') + '</span>';"
, " }"
, " if (r.course) {"
, " meta += '<span class=\\\'r-course\\\'>' + escapeHtml(r.course) + '</span>';"
, " }"
, " let metaHtml = meta ? '<div class=\\\'roux-recipe-meta\\\'>' + meta + '</div>' : '';"
, " let imgHtml = r.image ? '<img class=\\\'roux-index-thumb\\\' src=\\\'' + escapeHtml(r.image) + '\\\' alt=\\\'\\\' loading=\\\'lazy\\\'>' : '';"
, " return '<li>' + imgHtml + '<div class=\\\'roux-index-entry\\\'><a href=\\\"/recipes/' + encodeURIComponent(r.filename) + '\\\">' + escapeHtml(r.title) + '</a>' + metaHtml + '</div></li>';"
, "}"
, ""
, "function renderFlatList(items) {"
, " if (items.length === 0) return '<p>No recipes found.</p>';"
, " let currentLetter = '';"
, " let html = '<ul class=\\\'roux-recipes\\\'>';"
, " items.forEach(r => {"
, " const letter = r.title.charAt(0).toUpperCase();"
, " if (letter !== currentLetter) {"
, " currentLetter = letter;"
, " html += '<li class=\\\'roux-letter-divider\\\'>' + letter + '</li>';"
, " }"
, " html += renderRecipeEntry(r);"
, " });"
, " html += '</ul>';"
, " return html;"
, "}"
, ""
, "function renderFlatListNoAlpha(items) {"
, " if (items.length === 0) return '<p>No recipes found.</p>';"
, " let html = '<ul class=\\\'roux-recipes\\\'>';"
, " items.forEach(r => {"
, " html += renderRecipeEntry(r);"
, " });"
, " html += '</ul>';"
, " return html;"
, "}"
, ""
, "function renderByTags(items) {"
, " const tagMap = {};"
, " items.forEach(r => {"
, " if (r.tags.length === 0) return;"
, " r.tags.forEach(t => {"
, " if (!tagMap[t]) tagMap[t] = [];"
, " tagMap[t].push(r);"
, " });"
, " });"
, " const tags = Object.keys(tagMap).sort((a,b) => a.localeCompare(b));"
, " if (tags.length === 0) return '<p>No tagged recipes.</p>';"
, " let html = '';"
, " tags.forEach(t => {"
, " const sorted = tagMap[t].sort((a,b) => a.title.localeCompare(b.title));"
, " html += '<h3>' + escapeHtml(t) + '</h3>';"
, " html += renderFlatListNoAlpha(sorted);"
, " });"
, " return html;"
, "}"
, ""
, "function renderByCourse(items) {"
, " const courseMap = {};"
, " items.forEach(r => {"
, " if (!r.course) return;"
, " if (!courseMap[r.course]) courseMap[r.course] = [];"
, " courseMap[r.course].push(r);"
, " });"
, " const courses = Object.keys(courseMap).sort((a,b) => a.localeCompare(b));"
, " if (courses.length === 0) return '<p>No recipes with course metadata.</p>';"
, " let html = '';"
, " courses.forEach(c => {"
, " const sorted = courseMap[c].sort((a,b) => a.title.localeCompare(b.title));"
, " html += '<h3>' + escapeHtml(c) + '</h3>';"
, " html += renderFlatListNoAlpha(sorted);"
, " });"
, " return html;"
, "}"
, ""
, "function escapeHtml(s) {"
, " const d = document.createElement('div');"
, " d.appendChild(document.createTextNode(s));"
, " return d.innerHTML;"
, "}"
, ""
, "searchEl.addEventListener('input', render);"
, "window.addEventListener('hashchange', render);"
, ""
, "// Set active tab style based on hash"
, "function updateActiveNav() {"
, " const mode = getSortMode();"
, " document.querySelectorAll('.roux-navbar a').forEach(a => {"
, " a.classList.toggle('active', a.getAttribute('href') === '#' + mode);"
, " });"
, "}"
, "window.addEventListener('hashchange', updateActiveNav);"
, ""
, "// Initial render -- setting hash triggers hashchange"
, "if (!location.hash) location.hash = 'alpha';"
, "else { updateActiveNav(); render(); }"
, ""
, "})();"
]
-- ---------------------------------------------------------------------------
-- SSE live-reload scripts
-- ---------------------------------------------------------------------------
-- | Inline JavaScript for SSE live-reload on the recipe detail page.
sseRecipeJs :: Text
sseRecipeJs =
T.unlines
[ "(function(){"
, "'use strict';"
, "if (!window.EventSource) return;"
, "var el = document.getElementById('roux-current-recipe');"
, "if (!el) return;"
, "var currentRecipe = el.textContent;"
, "var es = new EventSource('/events');"
, "var lastReload = parseInt(sessionStorage.getItem('roux-last-reload') || '0', 10);"
, "es.addEventListener('recipe-changed', function(e) {"
, " if (e.data !== currentRecipe) return;"
, " var now = Date.now();"
, " if (now - lastReload < 3000) return;"
, " sessionStorage.setItem('roux-last-reload', String(now));"
, " location.reload();"
, "});"
, "})();"
]
-- | Inline JavaScript for SSE live-reload on the index page.
-- | Inline JavaScript for interactive recipe timers.
timerJs :: Text
timerJs =
T.unlines
[ "(function(){"
, "'use strict';"
, "var timers = document.querySelectorAll('.roux-timer-tag');"
, "timers.forEach(function(el) {"
, " var total = parseInt(el.getAttribute('data-seconds'), 10);"
, " if (!total || total <= 0) return;"
, " var remaining = total;"
, " var intervalId = null;"
, " var labelEl = el.querySelector('.roux-timer-label');"
, " var displayEl = el.querySelector('.roux-timer-display');"
, " var resetBtn = el.querySelector('.roux-timer-reset');"
, " var closeBtn = el.querySelector('.roux-timer-close');"
, " function formatTime(s) {"
, " var m = Math.floor(s / 60);"
, " var sec = s % 60;"
, " return m + ':' + (sec < 10 ? '0' : '') + sec;"
, " }"
, " function updateDisplay() {"
, " displayEl.textContent = formatTime(remaining);"
, " displayEl.classList.remove('done', 'pulse');"
, " if (remaining <= 0) {"
, " displayEl.classList.add('done');"
, " displayEl.classList.add('pulse');"
, " }"
, " }"
, " function startTimer() {"
, " if (intervalId) return;"
, " el.classList.add('running');"
, " updateDisplay();"
, " intervalId = setInterval(function() {"
, " remaining -= 1;"
, " if (remaining <= 0) {"
, " remaining = 0;"
, " clearInterval(intervalId);"
, " intervalId = null;"
, " }"
, " updateDisplay();"
, " }, 1000);"
, " }"
, " function resetTimer() {"
, " if (intervalId) { clearInterval(intervalId); intervalId = null; }"
, " remaining = total;"
, " updateDisplay();"
, " if (!el.classList.contains('running')) { el.classList.add('running'); }"
, " }"
, " function closeTimer() {"
, " if (intervalId) { clearInterval(intervalId); intervalId = null; }"
, " el.classList.remove('running');"
, " remaining = total;"
, " }"
, " el.addEventListener('click', function(e) {"
, " if (e.target === resetBtn || e.target === closeBtn) return;"
, " if (!el.classList.contains('running')) { startTimer(); }"
, " });"
, " if (resetBtn) resetBtn.addEventListener('click', function(e) {"
, " e.stopPropagation(); resetTimer();"
, " });"
, " if (closeBtn) closeBtn.addEventListener('click', function(e) {"
, " e.stopPropagation(); closeTimer();"
, " });"
, "});"
, "})();"
]
-- | Inline JavaScript for recipe image upload.
recipeImageUploadJs :: Text
recipeImageUploadJs =
T.unlines
[ "(function(){"
, "'use strict';"
, "var input = document.getElementById('roux-image-input');"
, "if (!input) return;"
, "input.addEventListener('change', function(e) {"
, " var file = e.target.files[0];"
, " if (!file) return;"
, " var filename = input.getAttribute('data-filename');"
, " if (!filename) return;"
, " var reader = new FileReader();"
, " reader.onload = function(ev) {"
, " var b64 = ev.target.result.split(',')[1];"
, " var formData = new URLSearchParams();"
, " formData.set('file-b64', b64);"
, " formData.set('file-name', file.name);"
, " formData.set('filename', filename);"
, " fetch('/upload-image', { method: 'POST', body: formData })"
, " .then(function(r) {"
, " if (!r.ok) { r.text().then(function(t) { alert('Upload failed: ' + t); }); return; }"
, " location.reload();"
, " })"
, " .catch(function(err) { alert('Upload error: ' + err); });"
, " };"
, " reader.readAsDataURL(file);"
, "});"
, "})();"
]
-- | Inline JavaScript for in-place recipe title editing.
titleEditJs :: Text
titleEditJs =
T.unlines
[ "(function(){"
, "'use strict';"
, "var h1 = document.querySelector('.roux-recipe-title');"
, "if (!h1) return;"
, "var originalText = '';"
, "var filenameEl = document.getElementById('roux-current-recipe');"
, "if (!filenameEl) return;"
, "var recipeFilename = filenameEl.textContent.trim();"
, ""
, "h1.addEventListener('click', function(e) {"
, " if (h1.getAttribute('contenteditable') === 'true') return;"
, " originalText = h1.textContent.trim();"
, " h1.setAttribute('contenteditable', 'true');"
, " h1.classList.add('roux-editing');"
, " var range = document.createRange();"
, " var sel = window.getSelection();"
, " range.selectNodeContents(h1);"
, " range.collapse(false);"
, " sel.removeAllRanges();"
, " sel.addRange(range);"
, "});"
, ""
, "h1.addEventListener('keydown', function(e) {"
, " if (e.key === 'Enter' && !e.shiftKey) {"
, " e.preventDefault();"
, " h1.blur();"
, " } else if (e.key === 'Escape') {"
, " e.preventDefault();"
, " h1.textContent = originalText;"
, " h1.removeAttribute('contenteditable');"
, " h1.classList.remove('roux-editing');"
, " }"
, "});"
, ""
, "h1.addEventListener('blur', function() {"
, " if (h1.getAttribute('contenteditable') !== 'true') return;"
, " var newText = h1.textContent.trim();"
, " if (newText === originalText || !newText) {"
, " h1.textContent = originalText;"
, " h1.removeAttribute('contenteditable');"
, " h1.classList.remove('roux-editing');"
, " return;"
, " }"
, " h1.removeAttribute('contenteditable');"
, " h1.classList.remove('roux-editing');"
, " var xhr = new XMLHttpRequest();"
, " xhr.open('PATCH', '/recipes/' + encodeURIComponent(recipeFilename) + '/title', true);"
, " xhr.setRequestHeader('Content-Type', 'application/json');"
, " xhr.onload = function() {"
, " if (xhr.status === 200) {"
, " try {"
, " var resp = JSON.parse(xhr.responseText);"
, " h1.textContent = resp.title || newText;"
, " } catch(e) {"
, " h1.textContent = newText;"
, " }"
, " } else {"
, " h1.textContent = originalText;"
, " h1.classList.add('roux-title-error');"
, " setTimeout(function() { h1.classList.remove('roux-title-error'); }, 2000);"
, " }"
, " };"
, " xhr.onerror = function() {"
, " h1.textContent = originalText;"
, " h1.classList.add('roux-title-error');"
, " setTimeout(function() { h1.classList.remove('roux-title-error'); }, 2000);"
, " };"
, " xhr.send(JSON.stringify({ title: newText }));"
, "});"
, "})();"
]
-- | Inline JavaScript for cook log form submission.
cookLogJs :: Text
cookLogJs =
T.unlines
[ "(function(){"
, "'use strict';"
, "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');"
, "var form = document.getElementById('roux-record-form');"
, "var dateInput = document.getElementById('roux-record-date');"
, "var commentInput = document.getElementById('roux-record-comment');"
, "var errorEl = document.getElementById('roux-record-error');"
, "var activeBtn = null;"
, "if (!btns.length || !modal || !form) return;"
, ""
, "function showModal() {"
, " activeBtn = this;"
, " dateInput.value = new Date().toISOString().slice(0,10);"
, " commentInput.value = '';"
, " errorEl.style.display = 'none';"
, " modal.style.display = 'block';"
, " dateInput.focus();"
, "}"
, ""
, "function hideModal() {"
, " modal.style.display = 'none';"
, "}"
, ""
, "btns.forEach(function(b) { b.addEventListener('click', showModal); });"
, "if (cancel) cancel.addEventListener('click', hideModal);"
, "if (backdrop) backdrop.addEventListener('click', hideModal);"
, ""
, "document.addEventListener('keydown', function(e) {"
, " if (e.key === 'Escape' && modal.style.display === 'block') hideModal();"
, "});"
, ""
, "form.addEventListener('submit', async function(e) {"
, " e.preventDefault();"
, " var date = dateInput.value;"
, " var comment = commentInput.value;"
, " if (!activeBtn) return;"
, " var filename = activeBtn.dataset.filename;"
, " var body = 'cooked-date=' + encodeURIComponent(date);"
, " if (comment) body += '&comment=' + encodeURIComponent(comment);"
, " var res = await fetch('/cook-log/' + encodeURIComponent(filename), {"
, " method: 'POST',"
, " headers: { 'Content-Type': 'application/x-www-form-urlencoded' },"
, " body: body"
, " });"
, " if (!res.ok) {"
, " var err = await res.json();"
, " errorEl.textContent = err.error || 'Failed to log cooking';"
, " errorEl.style.display = 'block';"
, " return;"
, " }"
, " hideModal();"
, " var entryDiv = document.createElement('div');"
, " entryDiv.style.margin = '0.3rem 0';"
, " entryDiv.style.fontSize = '0.9rem';"
, " var dateLine = document.createElement('strong');"
, " dateLine.style.display = 'block';"
, " dateLine.style.textAlign = 'center';"
, " dateLine.style.color = 'var(--roux-muted)';"
, " dateLine.textContent = '\x2014\x2014 ' + date + ' \x2014\x2014';"
, " entryDiv.appendChild(dateLine);"
, " if (comment) {"
, " var commentSpan = document.createElement('span');"
, " commentSpan.style.display = 'block';"
, " commentSpan.style.marginTop = '0.2rem';"
, " commentSpan.textContent = comment;"
, " entryDiv.appendChild(commentSpan);"
, " }"
, " var container = document.querySelector('.roux-cook-entries');"
, " if (container) container.prepend(entryDiv);"
, "});"
, "})();"
]
sseIndexJs :: Text
sseIndexJs =
T.unlines
[ "(function(){"
, "'use strict';"
, "if (!window.EventSource) return;"
, "var es = new EventSource('/events');"
, "var lastReload = parseInt(sessionStorage.getItem('roux-last-reload') || '0', 10);"
, "es.addEventListener('recipe-changed', function() {"
, " var now = Date.now();"
, " if (now - lastReload < 3000) return;"
, " sessionStorage.setItem('roux-last-reload', String(now));"
, " location.reload();"
, "});"
, "})();"
]
-- | Render the recipe index page -- shell with embedded JSON + JS rendering.
indexPage :: SortMode -> [Idx.RecipeInfo] -> ByteString
indexPage _mode recipes =
page "Roux — Recipes" $ do
H.nav ! A.class_ "roux-navbar" $ do
H.ul $ H.li $ H.a ! A.class_ "roux-logo" ! A.href "/" $ "Roux"
H.ul $ do
H.li $ H.a ! A.href "#alpha" $ "A-Z"
H.li $ H.a ! A.href "#tags" $ "By Tag"
H.li $ H.a ! A.href "#course" $ "By Course"
H.li $ H.a ! A.href "/import" $ "Import"
H.li $ H.a ! A.href "/cook-history" $ "Cook History"
-- Search input
H.input
! A.type_ "search"
! A.id "roux-search"
! A.placeholder "Search recipes..."
! A.style "margin-bottom: 1rem;"
! A.autofocus ""
-- Container for JS-rendered recipe list
H.div ! A.id "roux-recipe-list" $ H.p "Loading recipes..."
-- Embedded JSON: recipe data
H.script ! A.id "roux-recipe-data" ! A.type_ "application/json" $
H.toHtml (decodeUtf8 (LB.toStrict (Fleece.encode (Fleece.encoder (FC.list Idx.recipeSearchEntrySchema)) (Prelude.map Idx.toSearchEntry (filterOk recipes)))))
-- Embedded JSON: error data
H.script ! A.id "roux-errors-data" ! A.type_ "application/json" $
H.toHtml (decodeUtf8 (LB.toStrict (Fleece.encode (Fleece.encoder (FC.list Idx.recipeSearchEntrySchema)) (Prelude.map Idx.toSearchEntry (filterBad recipes)))))
-- Inline JS
H.script ! A.type_ "text/javascript" $ H.preEscapedText searchJs
-- Inline JS for SSE live-reload
H.script ! A.type_ "text/javascript" $ H.preEscapedText sseIndexJs
where
filterOk = filter (isRight . Idx.riRecipe)
filterBad = filter (isLeft . Idx.riRecipe)
-- | Inline JavaScript for converting file uploads to base64 before form submission.
fileUploadJs :: Text
fileUploadJs =
T.unlines
[ "(function(){"
, "'use strict';"
, "var form = document.getElementById('import-form');"
, "var fileInput = document.getElementById('file-input');"
, "var b64Field = document.getElementById('file-b64');"
, "var nameField = document.getElementById('file-name');"
, "var mimeField = document.getElementById('file-mime');"
, ""
, "form.addEventListener('submit', function(e) {"
, " if (fileInput.files.length > 0) {"
, " e.preventDefault();"
, " var file = fileInput.files[0];"
, " var reader = new FileReader();"
, " reader.onload = function() {"
, " var b64 = reader.result.split(',')[1];"
, " b64Field.value = b64;"
, " nameField.value = file.name;"
, " mimeField.value = file.type;"
, " form.submit();"
, " };"
, " reader.readAsDataURL(file);"
, " }"
, "});"
, "})();"
]
-- ---------------------------------------------------------------------------
-- Import page (form + result)
-- ---------------------------------------------------------------------------
-- | Render the import form page, optionally with a validation error.
importPage :: Maybe Text -> ByteString
importPage merror =
page "Roux \8212 Import Recipe" $ do
H.nav ! A.class_ "roux-navbar" $ do
H.ul $ H.li $ H.a ! A.href "/" $ "\8592 Back"
H.ul $ H.li $ H.strong "Import Recipe"
case merror of
Just err -> H.p ! A.style "color: var(--roux-accent);" $ H.toHtml err
Nothing -> pure ()
H.p ! A.style "font-size: 0.85rem; opacity: 0.7; margin-bottom: 1.5rem;" $
"Paste a recipe URL, upload a PDF or image, or paste recipe text directly."
H.form ! A.method "POST" ! A.action "/import" ! A.id "import-form" $ do
-- URL input
H.label ! A.for "url" $ "Recipe URL"
H.input
! A.type_ "url"
! A.id "url"
! A.name "url"
! A.class_ "roux-import-input"
! A.placeholder "https://cooking.nytimes.com/recipes/..."
-- File upload
H.label ! A.for "file-input" $ "Or upload a PDF or image"
H.input
! A.type_ "file"
! A.id "file-input"
! A.accept ".pdf,image/*"
! A.style "width: 100%; margin-bottom: 1.5rem;"
-- Hidden fields for base64 file data (set by JS)
H.input ! A.type_ "hidden" ! A.id "file-b64" ! A.name "file-b64" ! A.value ""
H.input ! A.type_ "hidden" ! A.id "file-name" ! A.name "file-name" ! A.value ""
H.input ! A.type_ "hidden" ! A.id "file-mime" ! A.name "file-mime" ! A.value ""
-- Text area
H.label ! A.for "text" $ "Or paste recipe text"
H.textarea
! A.id "text"
! A.name "text"
! A.rows "8"
! A.class_ "roux-import-input"
! A.placeholder "Paste recipe text here..."
$ ""
-- Submit
H.button ! A.type_ "submit" $ "Import Recipe"
-- Inline JS for base64 file conversion
H.script ! A.type_ "text/javascript" $ H.preEscapedText fileUploadJs
-- | Render the import result page with an error message.
importResultPage :: ImportError -> ByteString
importResultPage (ImportError msg) =
page "Roux \8212 Import Error" $ do
H.nav ! A.class_ "roux-navbar" $ do
H.ul $ H.li $ H.a ! A.href "/" $ "\8592 Back"
H.ul $ H.li $ H.a ! A.href "/import" $ "Try again"
H.h2 "Import failed"
H.p $ H.toHtml msg
recipePage :: Idx.RecipeInfo -> [CookLog.CookEntry] -> ByteString
recipePage info entries =
case Idx.riRecipe info of
Left err ->
page "Parse Error" $ do
H.nav $ H.ul $ H.li $ H.a ! A.href "/" $ "← Back"
H.h2 "Parse Error"
H.p $ H.toHtml (Idx.riTitle info <> ": " <> T.pack err)
hiddenRecipeSpan info
sseScript
Right recipe ->
page (titleText recipe) $ do
renderRecipe (Idx.riFilename info) recipe entries
hiddenRecipeSpan info
sseScript
where
hiddenRecipeSpan rInfo =
H.span ! A.id "roux-current-recipe" ! A.style "display: none;" $
H.toHtml (T.pack (Idx.riFilename rInfo))
sseScript = do
H.script ! A.type_ "text/javascript" $ H.preEscapedText sseRecipeJs
H.script ! A.type_ "text/javascript" $ H.preEscapedText timerJs
H.script ! A.type_ "text/javascript" $ H.preEscapedText recipeImageUploadJs
H.script ! A.type_ "text/javascript" $ H.preEscapedText titleEditJs
H.script ! A.type_ "text/javascript" $ H.preEscapedText cookLogJs
-- | Format a Day as "Mon DD" e.g. "Mar 15".
formatDay :: Day -> Text
formatDay = T.pack . Time.formatTime Time.defaultTimeLocale "%b %e"
-- | 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.style $ H.toHtml recipePageStyles
H.div ! A.class_ "rp-page" $ do
-- Nav (matching landing page style)
H.nav ! A.class_ "rp-nav" $ do
H.a ! A.class_ "rp-logo" ! A.href "/" $ "ROUX"
H.div ! A.class_ "rp-nav-links" $ do
H.a ! A.href "/recipes" $ "Recipes"
H.a ! A.href "/cook-history" $ "Cook History"
let meta = recipeMetadata recipe
course = metaCourse meta
desc = metaDescription meta
-- Course label
case course of
Just c -> H.p ! A.class_ "rp-course" $ H.toHtml c
Nothing -> pure ()
-- Title (inline-editable via existing JS)
H.h1 ! A.class_ "roux-recipe-title rp-title" $ H.toHtml (titleText recipe)
-- Subtitle (from description)
case desc of
Just d -> H.p ! A.class_ "rp-sub" $ H.toHtml d
Nothing -> pure ()
-- Meta strip: last cook + source + log meal
H.div ! A.class_ "rp-meta-strip" $ do
H.div ! A.class_ "rp-meta-strip-last" $ rpLastCookInfo entries
H.div ! A.class_ "rp-meta-strip-actions" $ do
case metaSource meta of
Just url -> H.a ! A.class_ "rp-btn-ghost" ! A.href (H.toValue url) ! A.target "_blank" $ "\x2197 Original"
Nothing -> pure ()
H.button ! A.class_ "rp-btn roux-record-trigger" ! A.id "roux-record-btn" ! H.dataAttribute "filename" (H.toValue (T.pack filename)) $ "+ Log meal"
-- Hero image
rpHeroImage meta filename
-- Body grid
H.div ! A.class_ "rp-body-grid" $ do
-- Left column: servings, ingredients, method
H.div $ do
case metaServings meta of
Just s -> do
H.div ! A.class_ "rp-section-label" $ "Serves"
H.div ! A.class_ "rp-serves" $ H.toHtml (showServings s)
Nothing -> pure ()
let sections = NE.toList (recipeSections recipe)
H.div ! A.class_ "rp-section-label" $ "Ingredients"
rpIngredientList sections
H.div ! A.class_ "rp-section-label" $ "Method"
rpMethodList sections
-- Right column: cook history rail
H.aside $ do
H.div ! A.class_ "rp-section-label" $ do
H.span "Cook history"
H.button ! A.class_ "rp-btn-ghost roux-record-trigger" ! H.dataAttribute "filename" (H.toValue (T.pack filename)) $ "+ Add"
rpHistoryList entries
-- Tags
let tags = metaTags meta
unless (null tags) $
H.div ! A.class_ "rp-tags" $
mapM_ (\t -> H.span ! A.class_ "rp-tag" $ H.toHtml t) tags
-- Record meal modal (unchanged)
H.div ! A.id "roux-record-modal" ! A.style "display: none;" $ do
H.div ! A.class_ "roux-modal-backdrop" ! A.id "roux-modal-backdrop" $ ""
H.div ! A.class_ "roux-modal-dialog" $ do
H.h3 "Record meal"
H.form ! A.id "roux-record-form" $ do
H.label ! A.for "roux-record-date" $ "Date"
H.input ! A.type_ "date" ! A.id "roux-record-date" ! A.name "cooked-date" ! A.required ""
H.label ! A.for "roux-record-comment" $ "Comment (optional)"
H.textarea ! A.id "roux-record-comment" ! A.name "comment" ! A.placeholder "How did it go?" ! A.maxlength "500" $ ""
H.p ! A.id "roux-record-error" ! A.style "color: var(--roux-accent); font-size: 0.8rem; margin: 0 0 0.5rem; display: none;" $ ""
H.div ! A.class_ "roux-modal-actions" $ 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))
-- | Format servings info.
showServings :: (Int, Maybe Text) -> Text
showServings (n, Nothing) = T.pack (show n)
showServings (n, Just unit) = T.pack (show n) <> " " <> unit
-- | Format a duration.
showDuration :: Duration -> Text
showDuration d =
let amt = durationAmount d
amount = case denominator amt of
1 -> T.pack (show (numerator amt))
_ -> T.pack (show (toDouble amt))
unit = fromMaybe "" (durationUnit d)
in T.strip (amount <> " " <> unit)
-- | Convert a Duration to total seconds for interactive timer display.
durationToSeconds :: Duration -> Int
durationToSeconds d =
let amt = fromRational (durationAmount d) :: Double
unit = fmap T.toLower (durationUnit d)
multiplier = case unit of
Just "hours" -> 3600.0
Just "hour" -> 3600.0
Just "h" -> 3600.0
Just "minutes" -> 60.0
Just "minute" -> 60.0
Just "min" -> 60.0
Just "m" -> 60.0
Just "seconds" -> 1.0
Just "second" -> 1.0
Just "sec" -> 1.0
Just "s" -> 1.0
_ -> 60.0
in round (amt * multiplier)
-- | Roughly convert Rational to Double for display.
toDouble :: Rational -> Double
toDouble r = fromIntegral (numerator r) / fromIntegral (denominator r)
-- ---------------------------------------------------------------------------
-- Step items (inline rendering within a step)
-- ---------------------------------------------------------------------------
-- | Render one inline element within a step.
renderStepItem :: StepItem -> Html
renderStepItem (StepText t) = H.toHtml t
renderStepItem (StepIngredient ing) = do
H.span ! A.class_ "roux-ingredient-tag" $ H.toHtml (ingName ing)
case ingQuantity ing of
Nothing -> pure ()
Just q -> " " <> (H.span ! A.class_ "qty" $ H.toHtml (showQuantity q))
renderStepItem (StepCookware cw) =
H.span ! A.class_ "roux-cookware-tag" $
H.toHtml (cwName cw)
renderStepItem (StepTimer timer) =
let secs = T.pack (show (durationToSeconds (timerDuration timer)))
label = case timerName timer of
Just n -> n <> " " <> showDuration (timerDuration timer)
Nothing -> showDuration (timerDuration timer)
in H.span ! H.dataAttribute "seconds" (H.toValue secs) ! A.class_ "roux-timer-tag" $ do
H.span ! A.class_ "roux-timer-label" $ H.toHtml label
H.span ! A.class_ "roux-timer-controls" $ do
H.span ! A.class_ "roux-timer-display" $ H.toHtml (label <> "...")
H.button ! A.class_ "roux-timer-reset" $ "\8634"
H.button ! A.class_ "roux-timer-close" $ "\10005"
renderStepItem (StepRecipeRef ref) =
H.a ! A.href (H.toValue ("/recipes/" <> urlEncode (refPath ref <> ".cook"))) $
H.toHtml (refPath ref)
renderStepItem (StepEndComment t) =
H.span ! A.class_ "roux-comment-tag" $ H.em $ H.toHtml (" -- " <> t)
renderStepItem (StepComment t) =
H.span ! A.class_ "roux-comment-tag" $ H.em $ H.toHtml ("[- " <> t <> " -]")
renderStepItem StepBreak =
H.br
-- ---------------------------------------------------------------------------
-- URL encoding
-- ---------------------------------------------------------------------------
-- | Percent-encode a filename for use in a URL path segment.
urlEncode :: FilePath -> Text
urlEncode = T.pack . concatMap encodeChar
where
encodeChar c
| c == ' ' = "%20"
| c == '#' = "%23"
| c == '%' = "%25"
| otherwise = [c]
-- | Percent-decode a URL path segment back to a filename.
urlDecode :: Text -> FilePath
urlDecode t = concat (go [] (T.group t))
where
go acc [] = reverse acc
go acc (s : rest)
| s == "%20" = go (" " : acc) rest
| s == "%23" = go ("#" : acc) rest
| s == "%25" = go ("%" : acc) rest
| otherwise = go (T.unpack s : acc) rest
-- ---------------------------------------------------------------------------
-- Misc helpers
-- ---------------------------------------------------------------------------
-- | Check if an 'Either' is 'Left'.
isLeft :: Either a b -> Bool
isLeft (Left _) = True
isLeft _ = False
-- | Check if an 'Either' is 'Right'.
isRight :: Either a b -> Bool
isRight = not . isLeft
-- | Render the cook history page (all entries across recipes).
cookHistoryPage :: [(Text, [CookLog.CookEntry])] -> ByteString
cookHistoryPage grouped =
page "Cook History" $ do
H.nav ! A.class_ "roux-navbar" $ do
H.ul $ H.li $ H.a ! A.class_ "roux-logo" ! A.href "/" $ "Roux"
H.ul $ do
H.li $ H.a ! A.href "/recipes" $ "Recipes"
H.li $ H.a ! A.class_ "active" ! A.href "/cook-history" $ "Cook History"
H.h1 "Cook History"
mapM_ renderMonthGroup grouped
renderMonthGroup :: (Text, [CookLog.CookEntry]) -> Html
renderMonthGroup (month, entries) = do
H.h2 $ H.toHtml month
H.ul ! A.style "list-style: none; padding: 0;" $ do
mapM_ renderHistoryEntry entries
renderHistoryEntry :: CookLog.CookEntry -> Html
renderHistoryEntry entry = do
H.li ! A.style "margin-bottom: 0.5rem;" $ do
H.strong $ H.toHtml (formatDay (CookLog.ceCookedDate entry))
" — "
H.a ! A.href (H.toValue ("/recipes/" <> CookLog.ceRecipeFilename entry <> ".cook")) $
H.toHtml (CookLog.ceRecipeFilename entry)
unless (T.null (CookLog.ceComment entry)) $ do
H.br
H.span $ H.toHtml (CookLog.ceComment entry)
-- ---------------------------------------------------------------------------
-- Landing page
-- ---------------------------------------------------------------------------
-- | CSS for the landing page layout.
landingStyles :: Text
landingStyles =
T.unlines
[ ".lnd-page { max-width: 980px; margin: 0 auto; padding: 36px 48px 80px; }"
, ".lnd-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: 32px; font-size: 12px; letter-spacing: 0.12em; color: #6B5847; text-transform: uppercase; }"
, ".lnd-logo { font-family: \"Fraunces\", serif; font-size: 22px; font-weight: 500; letter-spacing: 0.08em; }"
, ".lnd-nav-links { display: flex; gap: 28px; }"
, ".lnd-nav-links a { color: #6B5847; text-decoration: none; }"
, ".lnd-nav-links a.active { color: #B85C38; }"
, ".lnd-nav-links a:hover { color: #B85C38; }"
, ""
, ".lnd-shuffle { background: rgba(184,92,56,0.08); border-radius: 12px; padding: 20px 24px; display: flex; justify-content: space-between; align-items: center; gap: 24px; margin-bottom: 36px; }"
, ".lnd-shuffle-meta { font-size: 12px; letter-spacing: 0.12em; color: #8A7560; text-transform: uppercase; margin-bottom: 4px; }"
, ".lnd-shuffle h3 { font-family: \"Fraunces\", serif; font-size: 20px; font-weight: 500; margin: 0; }"
, ".lnd-shuffle-actions { display: flex; gap: 14px; align-items: center; flex-shrink: 0; }"
, ""
, ".lnd-btn { background: #B85C38; color: #FDF6E7; border: none; border-radius: 8px; padding: 10px 18px; font-size: 13px; font-weight: 500; cursor: pointer; font-family: inherit; }"
, ".lnd-btn:hover { filter: brightness(0.95); }"
, ".lnd-btn-ghost { background: none; border: none; color: #B85C38; font-size: 13px; cursor: pointer; padding: 0; font-family: inherit; }"
, ".lnd-btn-ghost:hover { text-decoration: underline; }"
, ""
, ".lnd-shelf-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 14px; }"
, ".lnd-shelf { margin-bottom: 36px; }"
, ".lnd-shelf h3 { font-family: \"Fraunces\", serif; font-size: 20px; font-weight: 500; margin: 0; }"
, ".lnd-shelf-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }"
, ""
, ".lnd-card { cursor: pointer; }"
, ".lnd-card-image { width: 100%; aspect-ratio: 1.3; border-radius: 8px; background-size: cover; background-position: center; background: linear-gradient(135deg, #E8B86A, #C9893F 60%, #8A5A2A); transition: opacity 0.15s; }"
, ".lnd-card-image.contain { background-size: contain; background-repeat: no-repeat; background-color: var(--roux-bg); }"
, ".lnd-card:hover .lnd-card-image { opacity: 0.92; }"
, ".lnd-card a { text-decoration: none; color: inherit; display: block; }"
, ".lnd-card-title { font-family: \"Fraunces\", serif; font-size: 17px; font-weight: 500; margin: 10px 0 4px; }"
, ".lnd-card-meta { font-size: 12px; color: #8A7560; }"
, ""
, ".lnd-img-1 { background: linear-gradient(135deg, #E8B86A, #C9893F 60%, #8A5A2A); }"
, ".lnd-img-2 { background: linear-gradient(135deg, #A8C77A, #6B8E3D 60%, #3D5A1F); }"
, ".lnd-img-3 { background: linear-gradient(135deg, #D4A574, #9C6B3F 60%, #5C3A1F); }"
, ".lnd-img-4 { background: linear-gradient(135deg, #E8C46A, #B8902F 60%, #7A5E1A); }"
, ".lnd-img-5 { background: linear-gradient(135deg, #C7A878, #8E6B3D 60%, #5A4520); }"
, ".lnd-img-6 { background: linear-gradient(135deg, #B5D4A1, #7AA862 60%, #4D7038); }"
, ""
, "@media (max-width: 720px) { .lnd-page { padding: 24px 24px 60px; } .lnd-shelf-row { grid-template-columns: 1fr 1fr; } .lnd-shuffle { flex-direction: column; align-items: flex-start; } .lnd-shuffle-actions { width: 100%; justify-content: flex-end; } }"
]
-- | Inline JavaScript for the landing page shuffle and navigation.
landingJs :: Text
landingJs =
T.unlines
[ "(function(){"
, "'use strict';"
, "var data = JSON.parse(document.getElementById('lnd-recipe-data').textContent);"
, "var shuffleName = document.getElementById('lnd-shuffle-name');"
, "var shuffleBtn = document.getElementById('lnd-shuffle-btn');"
, "var openBtn = document.getElementById('lnd-open-btn');"
, ""
, "function pickRandom() {"
, " return data[Math.floor(Math.random() * data.length)];"
, "}"
, ""
, "function setCurrent(r) {"
, " shuffleName.textContent = r.title;"
, " openBtn.onclick = function() { window.location.href = '/recipes/' + encodeURIComponent(r.filename); };"
, " openBtn.style.cursor = 'pointer';"
, "}"
, ""
, "if (data.length > 0) { setCurrent(pickRandom()); }"
, "shuffleBtn.addEventListener('click', function() { setCurrent(pickRandom()); });"
, "})();"
]
{- | Render the landing page with recently cooked (from cook log DB),
recently added recipes, and a shuffle card.
-}
landingPage :: [Idx.RecipeInfo] -> [(CookLog.CookEntry, Idx.RecipeInfo)] -> [Idx.RecipeInfo] -> ByteString
landingPage allRecipes recentlyCooked recentlyAdded =
page "Roux" $ do
H.style $ H.toHtml landingStyles
H.div ! A.class_ "lnd-page" $ do
-- Nav
H.nav ! A.class_ "lnd-nav" $ do
H.span ! A.class_ "lnd-logo" $ "ROUX"
H.div ! A.class_ "lnd-nav-links" $ do
H.a ! A.class_ "active" $ "Home"
H.a ! A.href "/recipes" $ "Recipes"
H.a ! A.href "/cook-history" $ "Cook History"
-- Shuffle card
H.div ! A.class_ "lnd-shuffle" $ do
H.div $ do
H.div ! A.class_ "lnd-shuffle-meta" $ "Don\x2019t know what to cook?"
H.h3 ! A.id "lnd-shuffle-name" $ "Loading..."
H.div ! A.class_ "lnd-shuffle-actions" $ do
H.button ! A.class_ "lnd-btn-ghost" ! A.id "lnd-shuffle-btn" $ "\x21BB Shuffle"
H.button ! A.class_ "lnd-btn" ! A.id "lnd-open-btn" $ "Open recipe"
-- Recently cooked shelf
H.section ! A.class_ "lnd-shelf" $ do
H.div ! A.class_ "lnd-shelf-header" $ do
H.h3 "Recently cooked"
H.a ! A.class_ "lnd-btn-ghost" ! A.href "/cook-history" $ "All history \x2192"
H.div ! A.class_ "lnd-shelf-row" $ do
let cookedGrads = ["lnd-img-1", "lnd-img-2", "lnd-img-3"]
mapM_ (\(i, (e, r)) -> renderLandingCookedCard (cookedGrads !! i) e r) (zip [0 ..] (take 3 recentlyCooked))
-- Recently added shelf
H.section ! A.class_ "lnd-shelf" $ do
H.div ! A.class_ "lnd-shelf-header" $ do
H.h3 "Recently added"
H.a ! A.class_ "lnd-btn-ghost" ! A.href "/recipes" $ "All recipes \x2192"
H.div ! A.class_ "lnd-shelf-row" $ do
let addedGrads = ["lnd-img-4", "lnd-img-5", "lnd-img-6"]
mapM_ (\(i, r) -> renderLandingCard (addedGrads !! i) r) (zip [0 ..] (take 3 recentlyAdded))
-- Embedded JSON for shuffle
H.script ! A.id "lnd-recipe-data" ! A.type_ "application/json" $
H.toHtml (encodeRecipeJson (take 100 (filterOk allRecipes)))
H.script ! A.type_ "text/javascript" $ H.preEscapedText landingJs
where
filterOk = filter (isRight . Idx.riRecipe)
-- | Encode recipe list as JSON for the shuffle JS.
encodeRecipeJson :: [Idx.RecipeInfo] -> Text
encodeRecipeJson recipes =
let entries = Prelude.map toSimpleEntry recipes
items = T.intercalate ", " entries
in "[" <> items <> "]"
where
toSimpleEntry r =
let title = escapeJson (Idx.riTitle r)
filename = escapeJson (T.pack (Idx.riFilename r))
in "{\"title\":\"" <> title <> "\",\"filename\":\"" <> filename <> "\"}"
escapeJson = T.concatMap escapeChar
escapeChar '"' = "\\\""
escapeChar '\\' = "\\\\"
escapeChar '\n' = "\\n"
escapeChar '\r' = "\\r"
escapeChar '\t' = "\\t"
escapeChar c = T.singleton c
-- | Get the recipe image URL from RecipeInfo, if available.
getRecipeImage :: Idx.RecipeInfo -> Maybe Text
getRecipeImage info = case Idx.riRecipe info of
Right r -> metaImage (recipeMetadata r)
Left _ -> Nothing
-- | Render a landing page card for recently cooked (with date).
renderLandingCookedCard :: Text -> CookLog.CookEntry -> Idx.RecipeInfo -> Html
renderLandingCookedCard imgClass entry info =
H.a ! A.href (H.toValue ("/recipes/" <> T.pack (Idx.riFilename info))) $
H.div ! A.class_ "lnd-card" $ do
case getRecipeImage info of
Just url -> H.div ! A.class_ "lnd-card-image contain" ! A.style (H.toValue ("background-image: url(" <> url <> ")")) $ ""
Nothing -> H.div ! A.class_ (H.toValue ("lnd-card-image " <> imgClass)) $ ""
H.div ! A.class_ "lnd-card-title" $ H.toHtml (Idx.riTitle info)
H.div ! A.class_ "lnd-card-meta" $ H.toHtml (formatDay (CookLog.ceCookedDate entry))
-- | Render a landing page card for recently added (with "Added" date).
renderLandingCard :: Text -> Idx.RecipeInfo -> Html
renderLandingCard imgClass info =
H.a ! A.href (H.toValue ("/recipes/" <> T.pack (Idx.riFilename info))) $
H.div ! A.class_ "lnd-card" $ do
case getRecipeImage info of
Just url -> H.div ! A.class_ "lnd-card-image contain" ! A.style (H.toValue ("background-image: url(" <> url <> ")")) $ ""
Nothing -> H.div ! A.class_ (H.toValue ("lnd-card-image " <> imgClass)) $ ""
H.div ! A.class_ "lnd-card-title" $ H.toHtml (Idx.riTitle info)
H.div ! A.class_ "lnd-card-meta" $ H.toHtml ("Recently added" :: Text)