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

';" + , " }" + , " 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 +