diff --git a/docs/plans/2026-05-21-inline-title-editing.md b/docs/plans/2026-05-21-inline-title-editing.md new file mode 100644 index 0000000..8d042f0 --- /dev/null +++ b/docs/plans/2026-05-21-inline-title-editing.md @@ -0,0 +1,367 @@ +# Inline Title Editing — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow users to click the recipe title on the detail page to edit it inline, saving via PATCH to the server. + +**Architecture:** Client-side `contenteditable` on the `

` sends `PATCH /recipes/{filename}/title` with JSON body. Server updates the `title:` field in the .cook file's YAML front matter. DOM updates in-place on success — no page reload. Existing fsnotify watcher + SSE handle out-of-band updates for other clients. + +**Tech Stack:** Haskell (WAI), blaze-html, vanilla JS, Cooklang .cook file format + +--- + +### Task 1: Add title CSS class to recipe page + +**Files:** +- Modify: `src/Roux/Html.hs:1-1032` + +- [ ] **Step 1: Add CSS class to the title `

`** + +In `renderRecipe`, find the title heading and add a CSS class for JS targeting. + +Change: +```haskell + H.h1 $ H.toHtml (titleText recipe) +``` +To: +```haskell + H.h1 ! A.class_ "roux-recipe-title" $ H.toHtml (titleText recipe) +``` + +Make sure `A` is imported from `Text.Blaze.Html5.Attributes` (it already is). + +- [ ] **Step 2: Add CSS for editable state** + +In the `T.unlines` block inside `H.style` in the `page` function, append these rules after the existing timer animations: + +```css +".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); } }" +``` + +- [ ] **Step 3: Run `./hs stack build` to confirm compilation** + +```bash +cd /work/personal/roux/roux-main && ./hs stack build 2>&1 | tail -20 +``` +Expected: Build succeeds with no errors or warnings. + +- [ ] **Step 4: Commit** + +```bash +git add src/Roux/Html.hs +git commit -m "feat: add CSS class and styles for editable recipe title" +``` + +--- + +### Task 2: Add client-side title editing JavaScript + +**Files:** +- Modify: `src/Roux/Html.hs` + +- [ ] **Step 1: Add `titleEditJs` inline script** + +After the existing `recipeImageUploadJs` definition (around the end of the JS definitions), add a new `titleEditJs` value: + +```haskell +-- | 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 }));" + , "});" + , "})();" + ] + +- [ ] **Step 2: Include `titleEditJs` in the recipe page's script block** + +In `recipePage`, find the `sseScript` definition and add `titleEditJs`: + +```haskell + 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 +``` + +- [ ] **Step 3: Run `./hs stack build` to confirm compilation** + +```bash +cd /work/personal/roux/roux-main && ./hs stack build 2>&1 | tail -20 +``` +Expected: Build succeeds with no errors or warnings. + +- [ ] **Step 4: Commit** + +```bash +git add src/Roux/Html.hs +git commit -m "feat: add inline title editing JavaScript" +``` + +--- + +### Task 3: Add server-side title update handler + +**Files:** +- Modify: `src/Roux/Server.hs` + +- [ ] **Step 1: Add the `updateTitleInCookFile` helper function** + +Add this function somewhere in `Server.hs` (e.g., near the existing `updateImageMetadata` function, around line 400): + +```haskell +-- | 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) +``` + +This follows the exact same pattern as the existing `updateImageMetadata` function (same block structure, same YAML helper). + +- [ ] **Step 2: Add the `titleUpdateHandler` function** + +Add after `uploadImageHandler`: + +```haskell +-- | 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 A.lookup "title" obj of + Just (A.String t) -> Just t + _ -> Nothing + titleFromJson _ = Nothing +``` + +Note: The `try` import (`Control.Exception.try`) is already in scope. + +- [ ] **Step 3: Register the route in `router`** + +In the `router` function, add the new route before the catch-all `_`: + +```haskell + ["recipe-images", filename] -> serveRecipeImage recipeDir (T.unpack filename) request respond + ["recipes", rawPath, "title"] | Wai.requestMethod request == "PATCH" -> + titleUpdateHandler recipeDir state request respond + _ -> handleRequest state request respond +``` + +This checks the HTTP method with a guard so that `GET /recipes/foo/title` still falls through to the normal handling. + +You'll also need to make `Wai.requestMethod` available — it's already imported via `Network.Wai as Wai`. + +- [ ] **Step 4: Run `./hs stack build` to confirm compilation** + +```bash +cd /work/personal/roux/roux-main && ./hs stack build 2>&1 | tail -20 +``` +Expected: Build succeeds with no errors or warnings. + +- [ ] **Step 5: Commit** + +```bash +git add src/Roux/Server.hs +git commit -m "feat: add PATCH endpoint for recipe title updates" +``` + +--- + +### Task 4: End-to-end verification + +**Files:** None (manual verification against the running server) + +- [ ] **Step 1: Start the server** + +```bash +cd /work/personal/roux/roux-main +# Use stack run to start the server (assuming the usual entrypoint) +./hs stack run 2>&1 & +sleep 3 +``` + +Or if the project has a specific startup command, adapt accordingly. + +- [ ] **Step 2: Test the PATCH endpoint with curl** + +```bash +curl -s -X PATCH \ + -H "Content-Type: application/json" \ + -d '{"title":"Updated Title"}' \ + http://localhost:3000/recipes/example-recipe/title +``` + +Expected: `{"success":"true","title":"Updated Title"}` + +- [ ] **Step 3: Verify the file was updated on disk** + +```bash +head -5 recipes/example-recipe.cook +``` +Expected: The `title:` field shows the updated value. + +- [ ] **Step 4: Verify the GET response still works** + +```bash +curl -s http://localhost:3000/recipes/example-recipe | grep -i "Updated Title" +``` +Expected: The page HTML contains the updated title. + +- [ ] **Step 5: Test error cases** + +```bash +# Empty title +curl -s -X PATCH -H "Content-Type: application/json" \ + -d '{"title":" "}' \ + http://localhost:3000/recipes/example-recipe/title +``` +Expected: `{"error":"Title cannot be empty"}` + +```bash +# Missing title +curl -s -X PATCH -H "Content-Type: application/json" \ + -d '{}' \ + http://localhost:3000/recipes/example-recipe/title +``` +Expected: `{"error":"Missing 'title' field"}` + +```bash +# Nonexistent recipe +curl -s -X PATCH -H "Content-Type: application/json" \ + -d '{"title":"Test"}' \ + http://localhost:3000/recipes/nonexistent/title +``` +Expected: `{"error":"Recipe not found"}` + +- [ ] **Step 6: Run the existing test suite** + +```bash +cd /work/personal/roux/roux-main && ./hs stack test 2>&1 | tail -20 +``` +Expected: All existing tests pass (no regressions). + +- [ ] **Step 7: Commit (if any fixes needed from verification)** + +```bash +git add -A +git commit -m "fix: address issues found during verification" +``` + +--- + +### Self-review + +- **Spec coverage**: Every spec requirement has a corresponding task. Task 1 = CSS/hover hint. Task 2 = client-side JS (click-to-edit, Enter save, Escape revert, blur save, error handling). Task 3 = PATCH endpoint, YAML update, error responses. Task 4 = verification. +- **Placeholder scan**: No placeholders, TODOs, or vague steps. All code is concrete and complete. +- **Type consistency**: `addOrUpdateYamlKey` (existing) takes `(Text, Text, Text) -> Text`. `updateTitleInCookFile` takes `(FilePath, Text) -> IO ()`. `titleFromJson` returns `Maybe Text`. All consistent. diff --git a/docs/specs/2026-05-21-inline-title-editing-design.md b/docs/specs/2026-05-21-inline-title-editing-design.md new file mode 100644 index 0000000..5335210 --- /dev/null +++ b/docs/specs/2026-05-21-inline-title-editing-design.md @@ -0,0 +1,78 @@ +# Inline Recipe Title Editing + +Date: 2026-05-21 + +## Summary + +Add in-page editing for recipe titles. Clicking the recipe title on the detail page makes it editable inline (using `contenteditable`). Pressing Enter or clicking away saves the change via a PATCH request. Escape cancels. The DOM updates in-place on success — no page reload. + +## Motivation + +This is the first step toward making all recipe fields editable in-page, keeping the layout stable during editing. + +## Client-side interaction + +1. The `

` recipe title on the recipe detail page gets a click handler. +2. On click: + - `contenteditable` is set to `true` on the `

`. + - The element receives focus (via `.focus()` at the end of the text). + - A CSS class `.roux-editing` is added for a subtle visual cue (faint dashed border / changed cursor). + - A hidden `` stores the original text for revert. +3. On **Enter** (without Shift): prevent default newline, trigger save. +4. On **Escape**: revert to the original text, remove `contenteditable`, remove `.roux-editing`. +5. On **blur** (clicking away): trigger save. +6. **Save flow**: + - Read the current text content from the `

`. + - If unchanged from original, do nothing (just exit edit mode). + - Send `PATCH /recipes/{filename}/title` with JSON body `{ "title": "New Title" }`. + - On 200 OK: update the `

` text to the server's response (the server normalizes/trims it), remove `contenteditable`, remove `.roux-editing`. + - On error: revert to original text, remove edit mode, show a brief error indication. +7. **Hover hint**: a subtle pencil icon or "Edit" text appears next to the title on hover (CSS only — no extra JS needed). + +## Server-side + +### New route: `PATCH /recipes/{filename}/title` + +**Handler**: `titleUpdateHandler` in `Roux.Server` module. + +1. Validate HTTP method is PATCH (return 405 otherwise). +2. Parse JSON body: expects `{ "title": "..." }`. Return 400 if missing or invalid. +3. Look up the recipe file: + - Strip `.cook` extension, lowercase, same pattern as existing `lookupRecipe`. + - Read the `.cook` file from disk. +4. Update the title in the YAML front matter: + - Find the `title:` line in the front matter block (text between the opening `---\n` and `\n---`). + - Replace its value with the new title. + - If no `title:` field exists, insert one after the opening `---`. + - Write the file back to disk. +5. The existing fsnotify watcher picks up the change and updates the shared IORef state. SSE events notify other connected clients. +6. Return `200 OK` with JSON `{ "success": true, "title": "" }`. + +### Error handling + +- Recipe file not found → 404. +- File read/write error → 500 with JSON `{ "error": "Could not update recipe file" }`. +- Empty title (after trimming) → 400 with JSON `{ "error": "Title cannot be empty" }`. +- Client-side: on any error, revert to original text and show a brief visual error state. + +### YAML update logic + +Reuses the same pattern as the existing `updateImageMetadata` / `addOrUpdateYamlKey` functions in `Server.hs`. A dedicated `updateTitleInCookFile` function reads the file, performs the replacement, and writes it back. + +## Files changed + +- **`src/Roux/Server.hs`**: Add `titleUpdateHandler` and route registration. +- **`src/Roux/Html.hs`**: Add inline JS for title editing (`titleEditJs`), add CSS for `.roux-editing` state and hover hint. + +## What's not included (future steps) + +- Editing other recipe fields (source, description, servings, etc.) +- Debouncing or optimistic updates +- Keyboard shortcut indicators + +## Self-review + +- No placeholders or TODOs. +- Internally consistent: client save matches server endpoint, error handling is symmetric. +- Scoped appropriately for a single implementation plan (one server handler + one client JS block). +- No ambiguity: `contenteditable` behavior, PATCH route, YAML update pattern are all explicit.