7b64f9576b
Build and Deploy / build-and-deploy (push) Successful in 12m59s
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
1474 lines
78 KiB
Haskell
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(' · ') + '</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)
|