Merge branch 'feat/inline-title-edit'
Build and Deploy / build-and-deploy (push) Successful in 2m10s

This commit is contained in:
2026-05-21 21:37:20 -04:00
2 changed files with 142 additions and 1 deletions
+84 -1
View File
@@ -259,6 +259,12 @@ page title content =
, "@keyframes roux-timer-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.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, .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-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 H.body $ H.main ! A.class_ "container" $ content
@@ -543,6 +549,82 @@ recipeImageUploadJs =
, "})();" , "})();"
] ]
-- | 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 }));"
, "});"
, "})();"
]
sseIndexJs :: Text sseIndexJs :: Text
sseIndexJs = sseIndexJs =
T.unlines T.unlines
@@ -707,6 +789,7 @@ recipePage info =
H.script ! A.type_ "text/javascript" $ H.preEscapedText sseRecipeJs 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 timerJs
H.script ! A.type_ "text/javascript" $ H.preEscapedText recipeImageUploadJs H.script ! A.type_ "text/javascript" $ H.preEscapedText recipeImageUploadJs
H.script ! A.type_ "text/javascript" $ H.preEscapedText titleEditJs
-- | Render the body of a recipe page (no page shell). -- | Render the body of a recipe page (no page shell).
renderRecipe :: FilePath -> Recipe -> Html renderRecipe :: FilePath -> Recipe -> Html
@@ -721,7 +804,7 @@ renderRecipe filename recipe = do
Just c -> H.p ! A.class_ "roux-course" $ H.toHtml c Just c -> H.p ! A.class_ "roux-course" $ H.toHtml c
Nothing -> pure () Nothing -> pure ()
H.div ! A.style "display: flex; justify-content: space-between; align-items: baseline; gap: 1rem;" $ do H.div ! A.style "display: flex; justify-content: space-between; align-items: baseline; gap: 1rem;" $ do
H.h1 $ H.toHtml (titleText recipe) H.h1 ! A.class_ "roux-recipe-title" $ H.toHtml (titleText recipe)
case metaSource meta of case metaSource meta of
Just url -> H.a ! A.class_ "roux-source-link" ! A.href (H.toValue url) ! A.target "_blank" ! A.rel "noopener noreferrer" $ do Just url -> H.a ! A.class_ "roux-source-link" ! A.href (H.toValue url) ! A.target "_blank" ! A.rel "noopener noreferrer" $ do
"↗ Original" "↗ Original"
+58
View File
@@ -43,6 +43,7 @@ import System.FSNotify (
import System.FilePath (makeRelative, takeBaseName, takeExtension, (</>)) import System.FilePath (makeRelative, takeBaseName, takeExtension, (</>))
import qualified Data.Aeson as A import qualified Data.Aeson as A
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString as BS import qualified Data.ByteString as BS
import Data.Maybe (mapMaybe) import Data.Maybe (mapMaybe)
import qualified Data.Text.Encoding as TE import qualified Data.Text.Encoding as TE
@@ -183,6 +184,9 @@ router recipeDir config state changeLog request respond =
["import"] -> importHandler recipeDir config state request respond ["import"] -> importHandler recipeDir config state request respond
["upload-image"] -> uploadImageHandler recipeDir state request respond ["upload-image"] -> uploadImageHandler recipeDir state request respond
["recipe-images", filename] -> serveRecipeImage recipeDir (T.unpack filename) request respond ["recipe-images", filename] -> serveRecipeImage recipeDir (T.unpack filename) request respond
["recipes", _, "title"]
| Wai.requestMethod request == "PATCH" ->
titleUpdateHandler recipeDir state request respond
_ -> handleRequest state request respond _ -> handleRequest state request respond
{- | Handle GET and POST requests to /import. {- | Handle GET and POST requests to /import.
@@ -743,6 +747,20 @@ updateImageMetadata cookFilePath imageUrl = do
"---\nimage: " <> imageUrl <> "\n---\n\n" <> text "---\nimage: " <> imageUrl <> "\n---\n\n" <> text
BS.writeFile cookFilePath (TE.encodeUtf8 updated) BS.writeFile cookFilePath (TE.encodeUtf8 updated)
-- | Update the title in a .cook file's YAML front matter.
updateTitleInCookFile :: FilePath -> Text -> IO ()
updateTitleInCookFile filepath newTitle = do
content <- BS.readFile filepath
let text = TE.decodeUtf8 content
updated = case T.stripPrefix "---\n" text of
Just afterOpen ->
let (yamlBlock, rest) = T.breakOn "\n---" afterOpen
newYaml = addOrUpdateYamlKey "title" newTitle yamlBlock
in "---\n" <> newYaml <> rest
Nothing ->
"---\ntitle: " <> newTitle <> "\n---\n\n" <> text
BS.writeFile filepath (TE.encodeUtf8 updated)
-- | Add or update a key: value line in a YAML block, preserving other keys. -- | Add or update a key: value line in a YAML block, preserving other keys.
addOrUpdateYamlKey :: Text -> Text -> Text -> Text addOrUpdateYamlKey :: Text -> Text -> Text -> Text
addOrUpdateYamlKey key value yaml = addOrUpdateYamlKey key value yaml =
@@ -773,3 +791,43 @@ jsonError msg =
HTTP.status400 HTTP.status400
[("Content-Type", "application/json")] [("Content-Type", "application/json")]
(LB.fromStrict (encodeUtf8 ("{\"error\":\"" <> msg <> "\"}"))) (LB.fromStrict (encodeUtf8 ("{\"error\":\"" <> msg <> "\"}")))
-- | Handle PATCH /recipes/{filename}/title -- update the recipe title.
titleUpdateHandler :: FilePath -> IORef (Map FilePath Idx.RecipeInfo) -> Wai.Application
titleUpdateHandler recipeDir state request respond = do
recipes <- readIORef state
case Wai.pathInfo request of
["recipes", rawPath, "title"] -> do
let key = map toLower (stripCookExt (T.unpack rawPath))
case Map.lookup key recipes of
Nothing -> respond (jsonError "Recipe not found")
Just info -> do
body <- readRequestBody request
case A.decode body of
Nothing -> respond (jsonError "Invalid JSON body")
Just (val :: A.Value) ->
case titleFromJson val of
Nothing -> respond (jsonError "Missing 'title' field")
Just title -> do
let trimmed = T.strip title
if T.null trimmed
then respond (jsonError "Title cannot be empty")
else do
let filepath = recipeDir </> Idx.riFilename info
result <- try $ updateTitleInCookFile filepath trimmed
case result of
Left (_ :: IOException) ->
respond (jsonError "Could not update recipe file")
Right () ->
respond $
jsonOk
[ ("success", "true")
, ("title", trimmed)
]
_ -> respond notFound
where
titleFromJson :: A.Value -> Maybe Text
titleFromJson (A.Object obj) = case KM.lookup "title" obj of
Just (A.String t) -> Just t
_ -> Nothing
titleFromJson _ = Nothing