feat: add interactive countdown timers to recipe steps
Build and Deploy / build-and-deploy (push) Successful in 1m45s
Build and Deploy / build-and-deploy (push) Successful in 1m45s
Cooklang timer references (~{15%minutes}) now render as clickable
spans. Clicking one starts a live countdown displayed in-place
with MM:SS format. A reset button (↺) restarts the timer from
the original duration. A close button (✕) returns to the static
label. When the timer reaches zero, the display pulses and turns
rust-colored for 3 cycles.
Implementation:
- durationToSeconds helper converts Duration to total seconds
- Timer span uses data-seconds attribute for the total
- Label + controls (display, reset, close) structure pre-rendered
in HTML, toggled via a .running CSS class
- Timer JS initializes all .roux-timer-tag elements on page load
- Each timer manages its own setInterval, multiple timers can
run simultaneously across different steps
This commit is contained in:
+114
-6
@@ -232,6 +232,19 @@ page title content =
|
||||
, ".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); }"
|
||||
]
|
||||
|
||||
H.body $ H.main ! A.class_ "container" $ content
|
||||
@@ -414,6 +427,75 @@ sseRecipeJs =
|
||||
]
|
||||
|
||||
-- | 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();"
|
||||
, " });"
|
||||
, "});"
|
||||
, "})();"
|
||||
]
|
||||
|
||||
sseIndexJs :: Text
|
||||
sseIndexJs =
|
||||
T.unlines
|
||||
@@ -518,8 +600,9 @@ recipePage info =
|
||||
hiddenRecipeSpan rInfo =
|
||||
H.span ! A.id "roux-current-recipe" ! A.style "display: none;" $
|
||||
H.toHtml (T.pack (Idx.riFilename rInfo))
|
||||
sseScript =
|
||||
sseScript = do
|
||||
H.script ! A.type_ "text/javascript" $ H.preEscapedText sseRecipeJs
|
||||
H.script ! A.type_ "text/javascript" $ H.preEscapedText timerJs
|
||||
|
||||
-- | Render the body of a recipe page (no page shell).
|
||||
renderRecipe :: Recipe -> Html
|
||||
@@ -622,6 +705,26 @@ showDuration d =
|
||||
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)
|
||||
@@ -727,11 +830,16 @@ 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))
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user