diff --git a/src/Roux/Html.hs b/src/Roux/Html.hs
index 813c5b8..8ea3f56 100644
--- a/src/Roux/Html.hs
+++ b/src/Roux/Html.hs
@@ -16,14 +16,16 @@ module Roux.Html (
) where
import Control.Monad (unless)
+import Data.Aeson (encode)
import Data.ByteString.Lazy (ByteString)
+import qualified Data.ByteString.Lazy as LB
import Data.Function ((&))
-import Data.List (sortOn)
import qualified Data.List.NonEmpty as NE
-import Data.Maybe (catMaybes, fromMaybe, mapMaybe)
+import Data.Maybe (catMaybes, fromMaybe)
import Data.Ratio (denominator, numerator)
import Data.Text (Text)
import qualified Data.Text as T
+import Data.Text.Encoding (decodeUtf8)
import qualified Text.Blaze.Html.Renderer.Utf8 as R
import Text.Blaze.Html5 as H
import Text.Blaze.Html5.Attributes as A
@@ -204,115 +206,155 @@ page title content =
-- Index page
-- ---------------------------------------------------------------------------
--- | Render the recipe index page.
+-- | Inline JavaScript for client-side search + sort rendering.
+searchJs :: Text
+searchJs = T.unlines
+ [ "(function(){"
+ , "'use strict';"
+ , ""
+ , "const recipes = JSON.parse(document.getElementById('roux-recipe-data').textContent);"
+ , "const errors = JSON.parse(document.getElementById('roux-errors-data').textContent);"
+ , "const listEl = document.getElementById('roux-recipe-list');"
+ , "const searchEl = document.getElementById('roux-search');"
+ , ""
+ , "function getSortMode() {"
+ , " const h = location.hash.slice(1);"
+ , " if (h === 'tags') return 'tags';"
+ , " if (h === 'course') return 'course';"
+ , " return 'alpha';"
+ , "}"
+ , ""
+ , "function render() {"
+ , " const query = searchEl.value.toLowerCase().trim();"
+ , " const mode = getSortMode();"
+ , " let filtered = recipes;"
+ , " if (query) {"
+ , " filtered = recipes.filter(r =>"
+ , " r.title.toLowerCase().includes(query) ||"
+ , " r.filename.toLowerCase().includes(query)"
+ , " );"
+ , " }"
+ , " let html = '';"
+ , " if (query || mode === 'alpha') {"
+ , " const sorted = [...filtered].sort((a, b) => a.title.localeCompare(b.title));"
+ , " html = renderFlatList(sorted);"
+ , " } else if (mode === 'tags') {"
+ , " html = renderByTags(filtered);"
+ , " } else if (mode === 'course') {"
+ , " html = renderByCourse(filtered);"
+ , " }"
+ , " if (errors.length > 0) {"
+ , " html += '
Unparseable recipes
';"
+ , " errors.forEach(e => { html += '- ' + escapeHtml(e.title) + '
'; });"
+ , " html += '
';"
+ , " }"
+ , " listEl.innerHTML = html;"
+ , "}"
+ , ""
+ , "function renderFlatList(items) {"
+ , " if (items.length === 0) return 'No recipes found.
';"
+ , " let html = '';"
+ , " return html;"
+ , "}"
+ , ""
+ , "function renderByTags(items) {"
+ , " const tagMap = {};"
+ , " items.forEach(r => {"
+ , " if (r.tags.length === 0) return;"
+ , " r.tags.forEach(t => {"
+ , " if (!tagMap[t]) tagMap[t] = [];"
+ , " tagMap[t].push(r);"
+ , " });"
+ , " });"
+ , " const tags = Object.keys(tagMap).sort((a,b) => a.localeCompare(b));"
+ , " if (tags.length === 0) return 'No tagged recipes.
';"
+ , " let html = '';"
+ , " tags.forEach(t => {"
+ , " const sorted = tagMap[t].sort((a,b) => a.title.localeCompare(b.title));"
+ , " html += '' + escapeHtml(t) + '
';"
+ , " html += renderFlatList(sorted);"
+ , " });"
+ , " return html;"
+ , "}"
+ , ""
+ , "function renderByCourse(items) {"
+ , " const courseMap = {};"
+ , " items.forEach(r => {"
+ , " if (!r.course) return;"
+ , " if (!courseMap[r.course]) courseMap[r.course] = [];"
+ , " courseMap[r.course].push(r);"
+ , " });"
+ , " const courses = Object.keys(courseMap).sort((a,b) => a.localeCompare(b));"
+ , " if (courses.length === 0) return 'No recipes with course metadata.
';"
+ , " let html = '';"
+ , " courses.forEach(c => {"
+ , " const sorted = courseMap[c].sort((a,b) => a.title.localeCompare(b.title));"
+ , " html += '' + escapeHtml(c) + '
';"
+ , " html += renderFlatList(sorted);"
+ , " });"
+ , " return html;"
+ , "}"
+ , ""
+ , "function escapeHtml(s) {"
+ , " const d = document.createElement('div');"
+ , " d.appendChild(document.createTextNode(s));"
+ , " return d.innerHTML;"
+ , "}"
+ , ""
+ , "searchEl.addEventListener('input', render);"
+ , "window.addEventListener('hashchange', render);"
+ , ""
+ , "// Set active tab style based on hash"
+ , "function updateActiveNav() {"
+ , " const mode = getSortMode();"
+ , " document.querySelectorAll('.roux-navbar a').forEach(a => {"
+ , " a.classList.toggle('active', a.getAttribute('href') === '#' + mode);"
+ , " });"
+ , "}"
+ , "window.addEventListener('hashchange', updateActiveNav);"
+ , ""
+ , "// Initial render"
+ , "if (!location.hash) location.hash = 'alpha';"
+ , "updateActiveNav();"
+ , "render();"
+ , ""
+ , "})();"
+ ]
+
+-- | Render the recipe index page -- shell with embedded JSON + JS rendering.
indexPage :: SortMode -> [Idx.RecipeInfo] -> ByteString
-indexPage mode recipes =
+indexPage _mode recipes =
page "Roux — Recipes" $ do
H.div ! A.class_ "roux-navbar" $ do
H.ul $ H.li $ H.strong "Roux"
H.ul $ do
- H.li $ sortLink "A-Z" AlphaSort "/" mode
- H.li $ sortLink "By Tag" TagSort "/sorted/tags" mode
- H.li $ sortLink "By Course" CourseSort "/sorted/course" mode
- let good = filter (isRight . Idx.riRecipe) recipes
- case mode of
- AlphaSort -> renderAlpha good
- TagSort -> renderByTags good
- CourseSort -> renderByCourse good
- -- Show parse errors separately
- let bad = filter (isLeft . Idx.riRecipe) recipes
- unless (null bad) $ do
- H.h3 "Unparseable recipes"
- H.ul $ mapM_ (H.li . H.toHtml . Idx.riTitle) bad
-
--- | A navigation link for a sort mode, highlighted when active.
-sortLink :: Text -> SortMode -> Text -> SortMode -> Html
-sortLink label linkMode url currentMode =
- if linkMode == currentMode
- then H.a ! A.href (H.toValue url) ! A.role "button" ! A.class_ "contrast outline" $ H.toHtml label
- else H.a ! A.href (H.toValue url) $ H.toHtml label
-
--- | Render recipes sorted alphabetically by title.
-renderAlpha :: [Idx.RecipeInfo] -> Html
-renderAlpha recipes
- | null recipes = H.p "No recipes found."
- | otherwise =
- let sorted = sortOn alphaSortKey recipes
- in H.ul ! A.class_ "roux-recipes" $ mapM_ recipeItem sorted
-
--- | Render recipes grouped by tags.
-renderByTags :: [Idx.RecipeInfo] -> Html
-renderByTags recipes = do
- let tags = collectTags recipes
- if null tags
- then H.p "No tagged recipes."
- else mapM_ renderTagGroup tags
+ H.li $ H.a ! A.href "#alpha" $ "A-Z"
+ H.li $ H.a ! A.href "#tags" $ "By Tag"
+ H.li $ H.a ! A.href "#course" $ "By Course"
+ -- Search input
+ H.input
+ ! A.type_ "search"
+ ! A.id "roux-search"
+ ! A.placeholder "Search recipes..."
+ ! A.style "margin-bottom: 1rem;"
+ -- Container for JS-rendered recipe list
+ H.div ! A.id "roux-recipe-list" $ H.p "Loading recipes..."
+ -- Embedded JSON: recipe data
+ H.script ! A.id "roux-recipe-data" ! A.type_ "application/json" $
+ H.toHtml (decodeUtf8 (LB.toStrict (encode (Prelude.map Idx.toSearchEntry (filterOk recipes)))))
+ -- Embedded JSON: error data
+ H.script ! A.id "roux-errors-data" ! A.type_ "application/json" $
+ H.toHtml (decodeUtf8 (LB.toStrict (encode (Prelude.map Idx.toSearchEntry (filterBad recipes)))))
+ -- Inline JS
+ H.script ! A.type_ "text/javascript" $ H.toHtml searchJs
where
- collectTags rs =
- let entries = mapMaybe tagEntries rs
- allTags = concat entries
- uniqueTags = sortOn Prelude.id (nubOrd allTags)
- in [(tag, filter (hasTag tag) rs) | tag <- uniqueTags]
- tagEntries r = case Idx.riRecipe r of
- Right recipe ->
- let tags = metaTags (recipeMetadata recipe)
- in if null tags then Nothing else Just tags
- Left _ -> Nothing
- hasTag tag r = case Idx.riRecipe r of
- Right recipe -> tag `elem` metaTags (recipeMetadata recipe)
- Left _ -> False
+ filterOk = filter (isRight . Idx.riRecipe)
+ filterBad = filter (isLeft . Idx.riRecipe)
--- | Render recipes grouped by course/category.
-renderByCourse :: [Idx.RecipeInfo] -> Html
-renderByCourse recipes = do
- let groups = collectCourses recipes
- if null groups
- then H.p "No recipes with course metadata."
- else mapM_ renderCourseGroup groups
- where
- collectCourses rs =
- let entries = mapMaybe courseEntry rs
- uniqueCourses = sortOn Prelude.id (nubOrd (Prelude.map fst entries))
- in [(course, [r | r <- rs, courseOf r == Just course]) | course <- uniqueCourses]
- courseEntry r = case Idx.riRecipe r of
- Right recipe -> (,) <$> metaCourse (recipeMetadata recipe) <*> pure ()
- Left _ -> Nothing
- courseOf r = case Idx.riRecipe r of
- Right recipe -> metaCourse (recipeMetadata recipe)
- Left _ -> Nothing
-
--- | Render a group of recipes under a tag heading.
-renderTagGroup :: (Text, [Idx.RecipeInfo]) -> Html
-renderTagGroup (tag, rs) = do
- H.h3 $ H.toHtml tag
- H.ul ! A.class_ "roux-recipes" $ mapM_ recipeItem rs
-
--- | Render a group of recipes under a course heading.
-renderCourseGroup :: (Text, [Idx.RecipeInfo]) -> Html
-renderCourseGroup (course, rs) = do
- H.h3 $ H.toHtml course
- H.ul ! A.class_ "roux-recipes" $ mapM_ recipeItem rs
-
--- | Render a single recipe link on the index page.
-recipeItem :: Idx.RecipeInfo -> Html
-recipeItem info =
- H.li $
- H.a ! A.href (H.toValue ("/recipes/" <> urlEncode (Idx.riFilename info))) $
- H.toHtml (Idx.riTitle info)
-
--- | Sort key for alphabetical ordering by title, falling back to filename.
-alphaSortKey :: Idx.RecipeInfo -> Text
-alphaSortKey info = case Idx.riRecipe info of
- Right r -> case metaTitle (recipeMetadata r) of
- Just t -> t
- Nothing -> T.pack (takeBaseName (Idx.riFilename info))
- Left _ -> T.pack "zzzz"
-
--- ---------------------------------------------------------------------------
--- Recipe detail page
--- ---------------------------------------------------------------------------
-
--- | Render a full recipe detail page.
recipePage :: Idx.RecipeInfo -> ByteString
recipePage info =
case Idx.riRecipe info of
@@ -585,10 +627,6 @@ urlDecode t = concat (go [] (T.group t))
-- Misc helpers
-- ---------------------------------------------------------------------------
--- | Extract base filename without directory components.
-takeBaseName :: FilePath -> String
-takeBaseName = reverse . takeWhile (/= '/') . reverse
-
-- | Check if an 'Either' is 'Left'.
isLeft :: Either a b -> Bool
isLeft (Left _) = True
@@ -598,13 +636,4 @@ isLeft _ = False
isRight :: Either a b -> Bool
isRight = not . isLeft
-{- | Remove duplicate elements from a list while preserving order of first
-occurrence.
--}
-nubOrd :: (Eq a) => [a] -> [a]
-nubOrd = go []
- where
- go _ [] = []
- go seen (x : xs)
- | x `elem` seen = go seen xs
- | otherwise = x : go (x : seen) xs
+