feat: add recipe image upload with in-place metadata update
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:
2026-05-21 20:43:58 -04:00
parent 76fab5c716
commit a509f44300
2 changed files with 179 additions and 6 deletions
+53 -5
View File
@@ -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