diff --git a/src/Roux/Html.hs b/src/Roux/Html.hs index f38d62b..d6018d0 100644 --- a/src/Roux/Html.hs +++ b/src/Roux/Html.hs @@ -259,6 +259,12 @@ page title content = , "@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 @@ -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 = 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 timerJs 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). renderRecipe :: FilePath -> Recipe -> Html @@ -721,7 +804,7 @@ renderRecipe filename recipe = do Just c -> H.p ! A.class_ "roux-course" $ H.toHtml c Nothing -> pure () H.div ! A.style "display: flex; justify-content: space-between; align-items: baseline; gap: 1rem;" $ do - H.h1 $ H.toHtml (titleText recipe) + H.h1 ! A.class_ "roux-recipe-title" $ H.toHtml (titleText recipe) case metaSource meta of Just url -> H.a ! A.class_ "roux-source-link" ! A.href (H.toValue url) ! A.target "_blank" ! A.rel "noopener noreferrer" $ do "↗ Original" diff --git a/src/Roux/Server.hs b/src/Roux/Server.hs index 3ef78e7..a7967ef 100644 --- a/src/Roux/Server.hs +++ b/src/Roux/Server.hs @@ -43,6 +43,7 @@ import System.FSNotify ( import System.FilePath (makeRelative, takeBaseName, takeExtension, ()) import qualified Data.Aeson as A +import qualified Data.Aeson.KeyMap as KM import qualified Data.ByteString as BS import Data.Maybe (mapMaybe) 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 ["upload-image"] -> uploadImageHandler recipeDir state 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 {- | Handle GET and POST requests to /import. @@ -743,6 +747,20 @@ updateImageMetadata cookFilePath imageUrl = do "---\nimage: " <> imageUrl <> "\n---\n\n" <> text 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. addOrUpdateYamlKey :: Text -> Text -> Text -> Text addOrUpdateYamlKey key value yaml = @@ -773,3 +791,43 @@ jsonError msg = HTTP.status400 [("Content-Type", "application/json")] (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