From a509f44300db1eaccc2903c2d4f081bc632f4e74 Mon Sep 17 00:00:00 2001 From: James Brechtel Date: Thu, 21 May 2026 20:43:58 -0400 Subject: [PATCH] feat: add recipe image upload with in-place metadata update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can now upload an photo for any recipe directly from the view page. The upload button appears as either a 'Replace' overlay on existing images or an empty dashed placeholder for recipes without one. Upload flow: 1. Click upload button -> file picker opens (accepts image/*) 2. JavaScript reads the file as base64, POSTs to /upload-image with form fields: filename, file-b64, file-name 3. Server decodes the base64, saves to /. 4. Server updates the .cook file's YAML front matter, adding or updating the 'image:' key with a /recipe-images/ URL 5. Server returns JSON success, page reloads to show the image Server routes added: - POST /upload-image — accepts base64-encoded image upload - GET /recipe-images/ — serves saved recipe images (extension-whitelisted to .jpg/.jpeg/.png/.gif/.webp) Image files are stored alongside .cook files in the recipe directory and served via the /recipe-images/ path. The fsnotify watcher detects the .cook metadata change and triggers an SSE reload. --- src/Roux/Html.hs | 58 +++++++++++++++++++-- src/Roux/Server.hs | 127 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 179 insertions(+), 6 deletions(-) diff --git a/src/Roux/Html.hs b/src/Roux/Html.hs index 9dde13e..f38d62b 100644 --- a/src/Roux/Html.hs +++ b/src/Roux/Html.hs @@ -229,6 +229,14 @@ page title content = , ".roux-desc-row { display: flex; gap: 2rem; align-items: flex-start; margin-bottom: 1.5rem; }" , ".roux-desc-row .roux-desc { flex: 1; }" , ".roux-desc-row img { max-width: 280px; height: auto; border-radius: 6px; box-shadow: 0 2px 8px rgba(61, 44, 30, 0.1); }" + , ".roux-image-wrap { position: relative; display: inline-block; }" + , ".roux-image-wrap img { display: block; }" + , ".roux-image-overlay { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(61,44,30,0.6); color: #F5EFE0; font-size: 0.7rem; text-align: center; padding: 4px 0; cursor: pointer; opacity: 0; transition: opacity 0.15s; border-radius: 0 0 6px 6px; }" + , ".roux-image-wrap:hover .roux-image-overlay { opacity: 1; }" + , ".roux-image-empty { width: 280px; height: 180px; border: 2px dashed rgba(61,44,30,0.15); border-radius: 6px; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: border-color 0.15s; }" + , ".roux-image-empty:hover { border-color: var(--roux-accent); }" + , ".roux-image-placeholder { font-size: 0.8rem; color: rgba(61,44,30,0.35); cursor: pointer; }" + , ".roux-image-empty:hover .roux-image-placeholder { color: var(--roux-accent); }" , "@media (max-width: 768px) { .roux-desc-row { flex-direction: column; } .roux-desc-row img { max-width: 100%; } }" , "" , ".roux-ingredient-tag { border-bottom: 1.5px solid rgba(191, 148, 40, 0.35); }" @@ -503,6 +511,38 @@ timerJs = , "})();" ] +-- | Inline JavaScript for recipe image upload. +recipeImageUploadJs :: Text +recipeImageUploadJs = + T.unlines + [ "(function(){" + , "'use strict';" + , "var input = document.getElementById('roux-image-input');" + , "if (!input) return;" + , "input.addEventListener('change', function(e) {" + , " var file = e.target.files[0];" + , " if (!file) return;" + , " var filename = input.getAttribute('data-filename');" + , " if (!filename) return;" + , " var reader = new FileReader();" + , " reader.onload = function(ev) {" + , " var b64 = ev.target.result.split(',')[1];" + , " var formData = new URLSearchParams();" + , " formData.set('file-b64', b64);" + , " formData.set('file-name', file.name);" + , " formData.set('filename', filename);" + , " fetch('/upload-image', { method: 'POST', body: formData })" + , " .then(function(r) {" + , " if (!r.ok) { r.text().then(function(t) { alert('Upload failed: ' + t); }); return; }" + , " location.reload();" + , " })" + , " .catch(function(err) { alert('Upload error: ' + err); });" + , " };" + , " reader.readAsDataURL(file);" + , "});" + , "})();" + ] + sseIndexJs :: Text sseIndexJs = T.unlines @@ -656,7 +696,7 @@ recipePage info = sseScript Right recipe -> page (titleText recipe) $ do - renderRecipe recipe + renderRecipe (Idx.riFilename info) recipe hiddenRecipeSpan info sseScript where @@ -666,10 +706,11 @@ recipePage info = 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 -- | Render the body of a recipe page (no page shell). -renderRecipe :: Recipe -> Html -renderRecipe recipe = do +renderRecipe :: FilePath -> Recipe -> Html +renderRecipe filename recipe = do H.nav ! A.class_ "roux-navbar" $ do H.ul $ H.li $ H.a ! A.class_ "roux-logo" ! A.href "/" $ "Roux" H.ul $ H.li $ H.a ! A.href "/" $ "Recipes" @@ -690,8 +731,15 @@ renderRecipe recipe = do Just d -> H.p ! A.class_ "roux-desc" $ H.toHtml d Nothing -> pure () case metaImage meta of - Just img -> H.img ! A.src (H.toValue img) ! A.alt (H.toValue (titleText recipe)) - Nothing -> pure () + Just img -> do + H.div ! A.class_ "roux-image-wrap" $ do + H.img ! A.src (H.toValue img) ! A.alt (H.toValue (titleText recipe)) + H.label ! A.class_ "roux-image-overlay" ! A.for "roux-image-input" $ "Replace" + H.input ! A.type_ "file" ! A.accept "image/*" ! A.id "roux-image-input" ! H.dataAttribute "filename" (H.toValue (T.pack filename)) ! A.style "display: none;" + Nothing -> do + H.div ! A.class_ "roux-image-wrap roux-image-empty" $ do + H.label ! A.class_ "roux-image-placeholder" ! A.for "roux-image-input" $ "Upload photo" + H.input ! A.type_ "file" ! A.accept "image/*" ! A.id "roux-image-input" ! H.dataAttribute "filename" (H.toValue (T.pack filename)) ! A.style "display: none;" renderMetaBar meta let sections = NE.toList (recipeSections recipe) notes = collectNotes recipe diff --git a/src/Roux/Server.hs b/src/Roux/Server.hs index d9a58d5..a5118ee 100644 --- a/src/Roux/Server.hs +++ b/src/Roux/Server.hs @@ -40,7 +40,7 @@ import System.FSNotify ( watchDir, withManagerConf, ) -import System.FilePath (makeRelative, takeBaseName, ()) +import System.FilePath (makeRelative, takeBaseName, takeExtension, ()) import qualified Data.Aeson as A import qualified Data.ByteString as BS @@ -181,6 +181,8 @@ router recipeDir config state changeLog request respond = case Wai.pathInfo request of ["events"] -> sseHandler 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 _ -> handleRequest state request respond {- | Handle GET and POST requests to /import. @@ -645,3 +647,126 @@ notFound = HTTP.status404 [("Content-Type", "text/plain")] "Not found" + +-- --------------------------------------------------------------------------- +-- Image upload and serving +-- --------------------------------------------------------------------------- + +{- | Handle image upload requests (POST only). +Expects form fields: filename (recipe .cook name), file-b64 (base64 content), +file-name (original filename for extension detection). +-} +uploadImageHandler :: FilePath -> IORef (Map FilePath Idx.RecipeInfo) -> Wai.Application +uploadImageHandler recipeDir _state request respond = + case Wai.requestMethod request of + "POST" -> do + body <- readRequestBody request + let params = parseFormBody body + filename = maybe "" id (lookup "filename" params) + fileB64 = maybe "" id (lookup "file-b64" params) + originalName = maybe "" id (lookup "file-name" params) + if T.null filename + then respond (jsonError "Missing 'filename' parameter") + else + if T.null fileB64 + then respond (jsonError "Missing 'file-b64' parameter") + else do + let basename = T.pack (stripCookExt (T.unpack filename)) + ext = takeExtension (T.unpack originalName) + safeName = + map + (\c -> if isAlphaNum c || c == '.' || c == '-' || c == '_' then c else '_') + (T.unpack basename <> if null ext then ".jpg" else ext) + imageFilename = T.pack safeName + filepath = recipeDir safeName + case decode (TE.encodeUtf8 fileB64) of + Left err -> respond (jsonError ("Base64 decode failed: " <> T.pack err)) + Right content -> do + -- Save the image file + BS.writeFile filepath content + putStrLn $ "[roux] saved recipe image to " <> filepath + -- Update the .cook file with image metadata + let cookPath = recipeDir T.unpack filename + imageUrl = "/recipe-images/" <> imageFilename + exists <- doesFileExist cookPath + if not exists + then respond (jsonError "Recipe file not found") + else do + updateImageMetadata cookPath imageUrl + putStrLn $ "[roux] updated image metadata for " <> T.unpack filename + respond (jsonOk [("image", imageUrl)]) + _ -> respond notFound + +-- | Serve a static recipe image file from the recipe directory. +serveRecipeImage :: FilePath -> FilePath -> Wai.Application +serveRecipeImage recipeDir filename _request respond = do + -- Security: only allow known image extensions + let ext = map toLower (takeExtension filename) + if ext `notElem` [".jpg", ".jpeg", ".png", ".gif", ".webp"] + then respond notFound + else do + let filepath = recipeDir filename + exists <- doesFileExist filepath + if not exists + then respond notFound + else do + content <- BS.readFile filepath + let contentType = case ext of + ".jpg" -> "image/jpeg" + ".jpeg" -> "image/jpeg" + ".png" -> "image/png" + ".gif" -> "image/gif" + ".webp" -> "image/webp" + _ -> "application/octet-stream" + respond $ + Wai.responseLBS + HTTP.status200 + [("Content-Type", contentType)] + (LB.fromStrict content) + +{- | Update the @image:@ key in a recipe's YAML front matter. +If no front matter exists, one is created. +-} +updateImageMetadata :: FilePath -> Text -> IO () +updateImageMetadata cookFilePath imageUrl = do + content <- BS.readFile cookFilePath + let text = TE.decodeUtf8 content + updated = case T.stripPrefix "---\n" text of + Just afterOpen -> + let (yamlBlock, rest) = T.breakOn "\n---" afterOpen + newYaml = addOrUpdateYamlKey "image" imageUrl yamlBlock + in "---\n" <> newYaml <> rest + Nothing -> + "---\nimage: " <> imageUrl <> "\n---\n\n" <> text + BS.writeFile cookFilePath (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 = + let yamlLines = T.lines yaml + keyPrefix = key <> ":" + hasKey = any (\l -> keyPrefix `T.isPrefixOf` T.stripStart l) yamlLines + in if hasKey + then T.unlines (map (replaceKeyLine keyPrefix) yamlLines) + else yaml <> "\n" <> key <> ": " <> value + where + replaceKeyLine prefix line + | prefix `T.isPrefixOf` T.stripStart line = key <> ": " <> value + | otherwise = line + +-- | Build a JSON OK response with a list of key-value pairs. +jsonOk :: [(Text, Text)] -> Wai.Response +jsonOk pairs = + let inner = T.intercalate ", " (map (\(k, v) -> "\"" <> k <> "\":\"" <> v <> "\"") pairs) + in Wai.responseLBS + HTTP.status200 + [("Content-Type", "application/json")] + (LB.fromStrict (encodeUtf8 ("{" <> inner <> "}"))) + +-- | Build a JSON error response. +jsonError :: Text -> Wai.Response +jsonError msg = + Wai.responseLBS + HTTP.status400 + [("Content-Type", "application/json")] + (LB.fromStrict (encodeUtf8 ("{\"error\":\"" <> msg <> "\"}")))