feat: add recipe image upload with in-place metadata update
Build and Deploy / build-and-deploy (push) Successful in 2m4s
Build and Deploy / build-and-deploy (push) Successful in 2m4s
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 <recipe-dir>/<basename>.<ext> 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/<filename> — 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.
This commit is contained in:
+53
-5
@@ -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 { display: flex; gap: 2rem; align-items: flex-start; margin-bottom: 1.5rem; }"
|
||||||
, ".roux-desc-row .roux-desc { flex: 1; }"
|
, ".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-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%; } }"
|
, "@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); }"
|
, ".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 :: Text
|
||||||
sseIndexJs =
|
sseIndexJs =
|
||||||
T.unlines
|
T.unlines
|
||||||
@@ -656,7 +696,7 @@ recipePage info =
|
|||||||
sseScript
|
sseScript
|
||||||
Right recipe ->
|
Right recipe ->
|
||||||
page (titleText recipe) $ do
|
page (titleText recipe) $ do
|
||||||
renderRecipe recipe
|
renderRecipe (Idx.riFilename info) recipe
|
||||||
hiddenRecipeSpan info
|
hiddenRecipeSpan info
|
||||||
sseScript
|
sseScript
|
||||||
where
|
where
|
||||||
@@ -666,10 +706,11 @@ recipePage info =
|
|||||||
sseScript = do
|
sseScript = do
|
||||||
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
|
||||||
|
|
||||||
-- | Render the body of a recipe page (no page shell).
|
-- | Render the body of a recipe page (no page shell).
|
||||||
renderRecipe :: Recipe -> Html
|
renderRecipe :: FilePath -> Recipe -> Html
|
||||||
renderRecipe recipe = do
|
renderRecipe filename recipe = do
|
||||||
H.nav ! A.class_ "roux-navbar" $ 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.class_ "roux-logo" ! A.href "/" $ "Roux"
|
||||||
H.ul $ H.li $ H.a ! A.href "/" $ "Recipes"
|
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
|
Just d -> H.p ! A.class_ "roux-desc" $ H.toHtml d
|
||||||
Nothing -> pure ()
|
Nothing -> pure ()
|
||||||
case metaImage meta of
|
case metaImage meta of
|
||||||
Just img -> H.img ! A.src (H.toValue img) ! A.alt (H.toValue (titleText recipe))
|
Just img -> do
|
||||||
Nothing -> pure ()
|
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
|
renderMetaBar meta
|
||||||
let sections = NE.toList (recipeSections recipe)
|
let sections = NE.toList (recipeSections recipe)
|
||||||
notes = collectNotes recipe
|
notes = collectNotes recipe
|
||||||
|
|||||||
+126
-1
@@ -40,7 +40,7 @@ import System.FSNotify (
|
|||||||
watchDir,
|
watchDir,
|
||||||
withManagerConf,
|
withManagerConf,
|
||||||
)
|
)
|
||||||
import System.FilePath (makeRelative, takeBaseName, (</>))
|
import System.FilePath (makeRelative, takeBaseName, takeExtension, (</>))
|
||||||
|
|
||||||
import qualified Data.Aeson as A
|
import qualified Data.Aeson as A
|
||||||
import qualified Data.ByteString as BS
|
import qualified Data.ByteString as BS
|
||||||
@@ -181,6 +181,8 @@ router recipeDir config state changeLog request respond =
|
|||||||
case Wai.pathInfo request of
|
case Wai.pathInfo request of
|
||||||
["events"] -> sseHandler changeLog request respond
|
["events"] -> sseHandler changeLog request respond
|
||||||
["import"] -> importHandler recipeDir config state 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
|
_ -> handleRequest state request respond
|
||||||
|
|
||||||
{- | Handle GET and POST requests to /import.
|
{- | Handle GET and POST requests to /import.
|
||||||
@@ -645,3 +647,126 @@ notFound =
|
|||||||
HTTP.status404
|
HTTP.status404
|
||||||
[("Content-Type", "text/plain")]
|
[("Content-Type", "text/plain")]
|
||||||
"Not found"
|
"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 <> "\"}")))
|
||||||
|
|||||||
Reference in New Issue
Block a user