Merge branch 'feat/inline-title-edit'
Build and Deploy / build-and-deploy (push) Successful in 2m10s
Build and Deploy / build-and-deploy (push) Successful in 2m10s
This commit is contained in:
+84
-1
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user