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 <> "\"}")))