8807ba3851
Build and Deploy / build-and-deploy (push) Successful in 1m30s
Adds a .roux-desc-row flex container that places the recipe description text on the left and the image (from metaImage) on the right, using what was previously empty horizontal space. Image is capped at 280px, with a subtle shadow and rounded corners. Responsively stacks on mobile.
813 lines
38 KiB
Haskell
813 lines
38 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,
|
|
urlEncode,
|
|
urlDecode,
|
|
) where
|
|
|
|
import Control.Monad (unless)
|
|
import Data.ByteString.Lazy (ByteString)
|
|
import qualified Data.ByteString.Lazy as LB
|
|
import Data.Function ((&))
|
|
import qualified Data.List.NonEmpty as NE
|
|
import Data.Maybe (catMaybes, fromMaybe)
|
|
import Data.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 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.
|
|
data ImportError = ImportError Text
|
|
deriving stock (Eq, Show)
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Helpers used throughout
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
-- | Pair a label with a value if present.
|
|
pairWith :: a -> Maybe b -> Maybe (a, b)
|
|
pairWith _ Nothing = Nothing
|
|
pairWith a (Just b) = Just (a, b)
|
|
|
|
{- | Format a quantity for display.
|
|
| Format a quantity for natural display (e.g. @4 cups@, @1/2 tsp@).
|
|
-}
|
|
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; }"
|
|
, ".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); }"
|
|
, ""
|
|
, "/* Recipe list on index page */"
|
|
, ".roux-recipes { list-style: none; padding: 0; }"
|
|
, ".roux-recipes li { padding: 0.4rem 0; }"
|
|
, ".roux-recipes a { font-size: 1rem; display: block; color: var(--roux-text); }"
|
|
, ".roux-recipes a:hover { color: var(--roux-accent); }"
|
|
, ".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-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); }"
|
|
, "@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; }"
|
|
]
|
|
|
|
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(' · ') + '</span>';"
|
|
, " }"
|
|
, " if (r.course) {"
|
|
, " meta += '<span class=\\\'r-course\\\'>' + escapeHtml(r.course) + '</span>';"
|
|
, " }"
|
|
, " let metaHtml = meta ? '<div class=\\\'roux-recipe-meta\\\'>' + meta + '</div>' : '';"
|
|
, " return '<li><a href=\\\"/recipes/' + encodeURIComponent(r.filename) + '\\\">' + escapeHtml(r.title) + '</a>' + metaHtml + '</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.
|
|
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"
|
|
-- 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)
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- 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.form ! A.method "POST" ! A.action "/import" $ do
|
|
H.label ! A.for "url" $ "Recipe URL"
|
|
H.input
|
|
! A.type_ "url"
|
|
! A.id "url"
|
|
! A.name "url"
|
|
! A.placeholder "https://cooking.nytimes.com/recipes/..."
|
|
! A.required ""
|
|
! A.style "width: 100%; margin-bottom: 1rem;"
|
|
H.button ! A.type_ "submit" $ "Import Recipe"
|
|
|
|
-- | 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 -> ByteString
|
|
recipePage info =
|
|
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 recipe
|
|
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 =
|
|
H.script ! A.type_ "text/javascript" $ H.preEscapedText sseRecipeJs
|
|
|
|
-- | Render the body of a recipe page (no page shell).
|
|
renderRecipe :: Recipe -> Html
|
|
renderRecipe recipe = do
|
|
H.nav ! A.class_ "roux-navbar" $ do
|
|
H.ul $ H.li $ H.a ! A.class_ "roux-logo" ! A.href "/" $ "Roux"
|
|
H.ul $ H.li $ H.a ! A.href "/" $ "Recipes"
|
|
let meta = recipeMetadata recipe
|
|
course = metaCourse meta
|
|
desc = metaDescription meta
|
|
case course of
|
|
Just c -> H.p ! A.class_ "roux-course" $ H.toHtml c
|
|
Nothing -> pure ()
|
|
H.div ! A.style "display: flex; justify-content: space-between; align-items: baseline; gap: 1rem;" $ do
|
|
H.h1 $ H.toHtml (titleText recipe)
|
|
case metaSource meta of
|
|
Just url -> H.a ! A.class_ "roux-source-link" ! A.href (H.toValue url) ! A.target "_blank" ! A.rel "noopener noreferrer" $ do
|
|
"↗ Original"
|
|
Nothing -> pure ()
|
|
H.div ! A.class_ "roux-desc-row" $ do
|
|
case desc of
|
|
Just d -> H.p ! A.class_ "roux-desc" $ H.toHtml d
|
|
Nothing -> pure ()
|
|
case metaImage meta of
|
|
Just img -> H.img ! A.src (H.toValue img) ! A.alt (H.toValue (titleText recipe))
|
|
Nothing -> pure ()
|
|
renderMetaBar meta
|
|
let sections = NE.toList (recipeSections recipe)
|
|
notes = collectNotes recipe
|
|
H.div ! A.class_ "roux-grid" $ do
|
|
-- Ingredients column
|
|
H.div $ do
|
|
H.h2 "Ingredients"
|
|
mapM_ renderIngredientGroup sections
|
|
-- Method column
|
|
H.div $ do
|
|
H.h2 "Method"
|
|
renderMethodSections sections
|
|
-- Marginalia column
|
|
H.div ! A.class_ "roux-marginalia" $ do
|
|
case metaSource meta of
|
|
Just url -> H.div ! A.class_ "note" $ do
|
|
H.h2 "Source"
|
|
H.p $ H.toHtml url
|
|
Nothing -> pure ()
|
|
let recipeTags = metaTags meta
|
|
unless (null recipeTags) $ do
|
|
H.div ! A.class_ "note" $ do
|
|
H.h2 "Tags"
|
|
H.p $ H.toHtml (T.intercalate " · " recipeTags)
|
|
unless (null notes) $ do
|
|
H.h2 "Notes"
|
|
mapM_ renderMarginalNote notes
|
|
|
|
-- | Extract display title from recipe metadata or fallback.
|
|
titleText :: Recipe -> Text
|
|
titleText recipe = fromMaybe "Untitled Recipe" (metaTitle (recipeMetadata recipe))
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Metadata bar
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
-- | Render the horizontal metadata bar (serves, prep, cook, total).
|
|
renderMetaBar :: Metadata -> Html
|
|
renderMetaBar meta = do
|
|
let items =
|
|
catMaybes
|
|
[ pairWith (T.pack "Serves") (showServings <$> metaServings meta)
|
|
, pairWith (T.pack "Prep") (showDuration <$> metaPrepTime meta)
|
|
, pairWith (T.pack "Cook") (showDuration <$> metaCookTime meta)
|
|
, pairWith (T.pack "Total") (showDuration <$> metaTotalTime meta)
|
|
]
|
|
unless (null items) $
|
|
H.div ! A.class_ "roux-meta" $
|
|
mapM_
|
|
( \(label, value) ->
|
|
H.div ! A.class_ "roux-meta-item" $ do
|
|
H.p ! A.class_ "label" $ H.toHtml label
|
|
H.p ! A.class_ "value" $ H.toHtml value
|
|
)
|
|
items
|
|
-- Tags
|
|
let tags = metaTags meta
|
|
unless (null tags) $
|
|
H.p $
|
|
mapM_ (\t -> H.span ! A.class_ "tag" $ H.toHtml t) tags
|
|
|
|
-- | Format servings info.
|
|
showServings :: (Int, Maybe Text) -> Text
|
|
showServings (n, Nothing) = T.pack (show n)
|
|
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)
|
|
|
|
-- | Roughly convert Rational to Double for display.
|
|
toDouble :: Rational -> Double
|
|
toDouble r = fromIntegral (numerator r) / fromIntegral (denominator r)
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Ingredients column
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
-- | Render ingredients grouped by section.
|
|
renderIngredientGroup :: Section -> Html
|
|
renderIngredientGroup section = do
|
|
let ings = collectSectionIngredients section
|
|
unless (null ings) $ do
|
|
case sectionName section of
|
|
Just n -> H.p ! A.class_ "roux-subsection" $ H.toHtml n
|
|
Nothing -> pure ()
|
|
H.div $ mapM_ renderIngredientRow ings
|
|
|
|
-- | Render one ingredient row with checkbox.
|
|
renderIngredientRow :: (Text, Maybe Quantity) -> Html
|
|
renderIngredientRow (name, qty) =
|
|
H.label ! A.class_ "roux-ingredient-row" $ do
|
|
H.input ! A.type_ "checkbox"
|
|
H.span ! A.class_ "name" $ do
|
|
case qty of
|
|
Just q -> H.span ! A.class_ "qty" $ H.toHtml (showQuantity q <> " ")
|
|
Nothing -> pure ()
|
|
H.toHtml name
|
|
|
|
-- | Collect unique ingredients from a single section.
|
|
collectSectionIngredients :: Section -> [(Text, Maybe Quantity)]
|
|
collectSectionIngredients section =
|
|
let ings =
|
|
NE.toList (sectionBody section)
|
|
>>= bodyItemSteps
|
|
>>= unStep
|
|
>>= \case
|
|
StepIngredient i -> [i]
|
|
_ -> []
|
|
in dedupFirst ings []
|
|
|
|
-- | Deduplicate ingredients by name, keeping first occurrence.
|
|
dedupFirst :: [Ingredient] -> [(Text, Maybe Quantity)] -> [(Text, Maybe Quantity)]
|
|
dedupFirst [] acc = reverse acc
|
|
dedupFirst (i : rest) acc =
|
|
if any ((== ingName i) . fst) acc
|
|
then dedupFirst rest acc
|
|
else dedupFirst rest ((ingName i, ingQuantity i) : acc)
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Method column
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
-- | Render the method column: all sections with numbered steps.
|
|
renderMethodSections :: [Section] -> Html
|
|
renderMethodSections sections = do
|
|
let allSteps = concatMap sectionMethodSteps (filter isMethodSection sections)
|
|
mapM_ (uncurry renderMethodStep) (zip [1 ..] allSteps)
|
|
|
|
-- | Is this section a method section (not an ingredient listing)?
|
|
isMethodSection :: Section -> Bool
|
|
isMethodSection section =
|
|
case sectionName section of
|
|
Just n -> not (T.toLower n `elem` ["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)
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
-- | 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) =
|
|
H.span ! A.class_ "roux-timer-tag" $ do
|
|
case timerName timer of
|
|
Just n -> H.toHtml n >> " "
|
|
Nothing -> pure ()
|
|
H.toHtml (showDuration (timerDuration timer))
|
|
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
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- 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
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
-- | 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
|