{-# 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 += '
No recipes found.
';" , " let currentLetter = '';" , " let html = 'No recipes found.
';" , " let html = 'No tagged recipes.
';" , " let html = '';" , " tags.forEach(t => {" , " const sorted = tagMap[t].sort((a,b) => a.title.localeCompare(b.title));" , " html += 'No recipes with course metadata.
';" , " let html = '';" , " courses.forEach(c => {" , " const sorted = courseMap[c].sort((a,b) => a.title.localeCompare(b.title));" , " html += '