Add three browse modes to index page: alphabetical, by tag, by course

- Add SortMode type (AlphaSort | TagSort | CourseSort) to Roux.Html
- Routes: / (alpha), /sorted/tags, /sorted/course
- AlphaSort: recipes sorted by title (from metadata) or filename
- TagSort: recipes grouped under tag headings, empty state when none
- CourseSort: recipes grouped by course/category metadata
- Navigation bar in index page with active-highlighted buttons
- Add metaCourse field to Metadata model (+ emptyMetadata update)
- Add nubOrd helper for deduplicating tag/course lists
- All 26 tests passing, hlint clean
This commit is contained in:
2026-05-19 08:16:58 -04:00
parent 6de6b6e2b2
commit b0926ee60d
4 changed files with 125 additions and 11 deletions
+114 -8
View File
@@ -8,6 +8,7 @@ Blaze-html re-exports many common names (title, meta, step, a, b, …)
so name-shadowing is unavoidable with local bindings. so name-shadowing is unavoidable with local bindings.
-} -}
module Roux.Html ( module Roux.Html (
SortMode (..),
indexPage, indexPage,
recipePage, recipePage,
urlEncode, urlEncode,
@@ -17,8 +18,9 @@ module Roux.Html (
import Control.Monad (unless) import Control.Monad (unless)
import Data.ByteString.Lazy (ByteString) import Data.ByteString.Lazy (ByteString)
import Data.Function ((&)) import Data.Function ((&))
import Data.List (sortOn)
import qualified Data.List.NonEmpty as NE import qualified Data.List.NonEmpty as NE
import Data.Maybe (catMaybes, fromMaybe) import Data.Maybe (catMaybes, fromMaybe, mapMaybe)
import Data.Ratio (denominator, numerator) import Data.Ratio (denominator, numerator)
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
@@ -29,6 +31,17 @@ import Text.Blaze.Html5.Attributes as A
import qualified Roux.RecipeIndex as Idx import qualified Roux.RecipeIndex as Idx
import Roux.Types import Roux.Types
-- ---------------------------------------------------------------------------
-- Sort mode for the index page
-- ---------------------------------------------------------------------------
-- | How to group or sort recipes on the index page.
data SortMode
= AlphaSort
| TagSort
| CourseSort
deriving stock (Eq, Show)
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Helpers used throughout -- Helpers used throughout
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@@ -79,22 +92,104 @@ page title content =
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- | Render the recipe index page. -- | Render the recipe index page.
indexPage :: [Idx.RecipeInfo] -> ByteString indexPage :: SortMode -> [Idx.RecipeInfo] -> ByteString
indexPage recipes = indexPage mode recipes =
page "Roux — Recipes" $ do page "Roux — Recipes" $ do
H.nav $ H.ul $ H.li $ H.strong "Roux" H.nav $ 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
H.h2 "Recipes" H.h2 "Recipes"
H.p "Your personal recipe collection." H.p "Your personal recipe collection."
case filter (isRight . Idx.riRecipe) recipes of let good = filter (isRight . Idx.riRecipe) recipes
[] -> H.p "No recipes found." case mode of
good -> AlphaSort -> renderAlpha good
H.ul $ mapM_ (recipeItem . Idx.riFilename) good TagSort -> renderByTags good
CourseSort -> renderByCourse good
-- Show parse errors separately -- Show parse errors separately
let bad = filter (isLeft . Idx.riRecipe) recipes let bad = filter (isLeft . Idx.riRecipe) recipes
unless (null bad) $ do unless (null bad) $ do
H.h3 "Unparseable recipes" H.h3 "Unparseable recipes"
H.ul $ mapM_ (H.li . H.toHtml . Idx.riTitle) bad 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 $ mapM_ (recipeItem . Idx.riFilename) sorted
-- | 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"
-- | 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
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
-- | 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 $ mapM_ (recipeItem . Idx.riFilename) 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 $ mapM_ (recipeItem . Idx.riFilename) rs
-- | Render a single recipe link on the index page. -- | Render a single recipe link on the index page.
recipeItem :: FilePath -> Html recipeItem :: FilePath -> Html
recipeItem fp = recipeItem fp =
@@ -322,3 +417,14 @@ isLeft _ = False
-- | Check if an 'Either' is 'Right'. -- | Check if an 'Either' is 'Right'.
isRight :: Either a b -> Bool isRight :: Either a b -> Bool
isRight = not . isLeft 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
+2 -2
View File
@@ -364,8 +364,8 @@ groupSections items =
-- Items before the first header → unnamed section, then named -- Items before the first header → unnamed section, then named
(before, (RawSectionHeader name) : rest) -> (before, (RawSectionHeader name) : rest) ->
let pre = let pre =
[Section Nothing (makeNonEmpty (map rawToBody before)) [ Section Nothing (makeNonEmpty (map rawToBody before))
| not (null before) | not (null before)
] ]
named = collectNamedSection name rest named = collectNamedSection name rest
in pre ++ named in pre ++ named
+7 -1
View File
@@ -41,7 +41,13 @@ app recipeDir = do
handleRequest :: [Idx.RecipeInfo] -> Wai.Application handleRequest :: [Idx.RecipeInfo] -> Wai.Application
handleRequest recipes request respond = handleRequest recipes request respond =
case Wai.pathInfo request of case Wai.pathInfo request of
[] -> respond (htmlResponse (Html.indexPage recipes)) -- Index page (default: alphabetical)
[] -> respond (htmlResponse (Html.indexPage Html.AlphaSort recipes))
-- Index page (sorted by tags)
["sorted", "tags"] -> respond (htmlResponse (Html.indexPage Html.TagSort recipes))
-- Index page (sorted by course/category)
["sorted", "course"] -> respond (htmlResponse (Html.indexPage Html.CourseSort recipes))
-- Recipe detail page
["recipes", rawPath] -> ["recipes", rawPath] ->
case lookupRecipe (urlDecodePath rawPath) recipes of case lookupRecipe (urlDecodePath rawPath) recipes of
Just info -> respond (htmlResponse (Html.recipePage info)) Just info -> respond (htmlResponse (Html.recipePage info))
+2
View File
@@ -205,6 +205,7 @@ data Metadata = Metadata
, metaCookTime :: !(Maybe Duration) , metaCookTime :: !(Maybe Duration)
, metaDifficulty :: !(Maybe Text) , metaDifficulty :: !(Maybe Text)
, metaCuisine :: !(Maybe Text) , metaCuisine :: !(Maybe Text)
, metaCourse :: !(Maybe Text)
, metaDiet :: ![Text] , metaDiet :: ![Text]
, metaTags :: ![Text] , metaTags :: ![Text]
, metaImage :: !(Maybe Text) , metaImage :: !(Maybe Text)
@@ -226,6 +227,7 @@ emptyMetadata =
, metaCookTime = Nothing , metaCookTime = Nothing
, metaDifficulty = Nothing , metaDifficulty = Nothing
, metaCuisine = Nothing , metaCuisine = Nothing
, metaCourse = Nothing
, metaDiet = [] , metaDiet = []
, metaTags = [] , metaTags = []
, metaImage = Nothing , metaImage = Nothing