Add landing page with cook log integration
Build and Deploy / build-and-deploy (push) Failing after 1m15s

- New landing page (/) based on roux-landing-mockup.html layout
  - Shuffle card that suggests random recipes
  - Recently cooked shelf (driven by cook log DB)
  - Recently added shelf (by file modification time)
- Existing recipe index moved to /recipes
- Updated nav links throughout to point to /recipes
- All hlint warnings fixed
This commit is contained in:
2026-05-26 07:15:38 -04:00
parent edb682c8bc
commit d22c8a79d4
2 changed files with 214 additions and 17 deletions
+57 -15
View File
@@ -20,7 +20,8 @@ import Data.ByteString.Lazy (ByteString)
import qualified Data.ByteString.Lazy as LB
import Data.Char (digitToInt, isAlphaNum, toLower)
import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef, writeIORef)
import Data.List (isSuffixOf)
import Data.List (isSuffixOf, sortOn)
import Data.Ord (Down (Down))
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Text (Text)
@@ -29,7 +30,7 @@ import Data.Text.Encoding (encodeUtf8)
import qualified Network.HTTP.Types as HTTP
import qualified Network.Wai as Wai
import System.Directory (createDirectoryIfMissing, doesFileExist, findExecutable, makeAbsolute, removeFile)
import System.Directory (createDirectoryIfMissing, doesFileExist, findExecutable, getModificationTime, makeAbsolute, removeFile)
import System.FSNotify (
Event (..),
EventIsDirectory (..),
@@ -44,7 +45,7 @@ import System.FilePath (makeRelative, takeBaseName, takeExtension, (</>))
import qualified Data.Aeson as A
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString as BS
import Data.Maybe (fromMaybe, mapMaybe)
import Data.Maybe (catMaybes, fromMaybe, mapMaybe)
import qualified Data.Text.Encoding as TE
import Data.Time.Calendar (Day, fromGregorianValid)
import Data.Time.Clock (diffUTCTime, getCurrentTime, utctDay)
@@ -200,7 +201,7 @@ router recipeDir config state changeLog db request respond =
| Just filename <- dispatchLogPost pathInfo
, Wai.requestMethod request == "POST" ->
handleCookLogPost db filename request respond
_ -> handleRequest state db request respond
_ -> handleRequest recipeDir state db request respond
-- | Extract filename from /cook-log/{filename} POST path, if it matches.
dispatchLogPost :: [Text] -> Maybe Text
@@ -622,17 +623,29 @@ reReadRecipe dir path state changeLog = do
putStrLn $ "[roux] updated " <> path
-- | Route an incoming request to the appropriate handler.
handleRequest :: IORef (Map FilePath Idx.RecipeInfo) -> SQL.Connection -> Wai.Application
handleRequest state db request respond = do
handleRequest :: FilePath -> IORef (Map FilePath Idx.RecipeInfo) -> SQL.Connection -> Wai.Application
handleRequest recipeDir state db request respond = do
recipes <- readIORef state
let recipeList = Map.elems recipes
case Wai.pathInfo request of
-- Index page (default: alphabetical)
[] -> respond (htmlResponse (Html.indexPage Html.AlphaSort recipeList))
-- Index page (sorted by tags) — sort mode ignored, driven client-side
["sorted", "tags"] -> respond (htmlResponse (Html.indexPage Html.AlphaSort recipeList))
-- Index page (sorted by course/category) — sort mode ignored, driven client-side
["sorted", "course"] -> respond (htmlResponse (Html.indexPage Html.AlphaSort recipeList))
-- Landing page (root)
[] -> do
allEntries <- CookLog.fetchAllEntries db
let okRecipes = filter (\r -> case Idx.riRecipe r of Right _ -> True; _ -> False) recipeList
-- Deduplicate cook entries by recipe filename, keep most recent
let deduped = deduplicateByRecipe allEntries
-- Look up recipe info for each recently cooked entry
lookupInfo e = case Map.lookup (map toLower (stripCookExt (T.unpack (CookLog.ceRecipeFilename e)))) recipes of
Just info -> Just (e, info)
Nothing -> Nothing
cookedWithInfo = mapMaybe lookupInfo (take 10 deduped)
-- Get recently added recipes (by modification time)
recentlyAdded <- getRecentlyAdded recipeDir okRecipes
respond $ htmlResponse (Html.landingPage okRecipes cookedWithInfo recentlyAdded)
-- Recipe index page
["recipes"] -> respond (htmlResponse (Html.indexPage Html.AlphaSort recipeList))
["recipes", "sorted", "tags"] -> respond (htmlResponse (Html.indexPage Html.AlphaSort recipeList))
["recipes", "sorted", "course"] -> respond (htmlResponse (Html.indexPage Html.AlphaSort recipeList))
-- Recipe detail page
["recipes", rawPath] ->
case lookupRecipe (urlDecodePath rawPath) recipes of
@@ -665,9 +678,38 @@ handleRequest state db request respond = do
(A.encode entries)
_ -> respond notFound
-- | Handle POST /cook-log/{filename}
-- | Normalize a recipe filename to match the recipe index key:
-- lowercased, with @.cook@ extension stripped.
-- | Deduplicate cook entries by recipe filename, keeping the most recent entry per recipe.
deduplicateByRecipe :: [CookLog.CookEntry] -> [CookLog.CookEntry]
deduplicateByRecipe entries =
let seen = foldl' addIfNew [] entries
in reverse seen
where
addIfNew acc e =
if any ((== CookLog.ceRecipeFilename e) . CookLog.ceRecipeFilename) acc
then acc
else e : acc
-- | Get recently added recipes sorted by modification time (newest first).
getRecentlyAdded :: FilePath -> [Idx.RecipeInfo] -> IO [Idx.RecipeInfo]
getRecentlyAdded recipeDir recipes = do
times <-
mapM
( \r -> do
let filepath = recipeDir </> Idx.riFilename r
result <- try (getModificationTime filepath)
case result of
Left (_ :: IOException) -> pure Nothing
Right t -> pure (Just (r, t))
)
recipes
let valid = catMaybes times
sorted = sortOn (Down . snd) valid
pure (Prelude.map fst (take 10 sorted))
{- | Handle POST /cook-log/{filename}
| Normalize a recipe filename to match the recipe index key:
lowercased, with @.cook@ extension stripped.
-}
normalizeRecipeKey :: Text -> Text
normalizeRecipeKey = T.pack . map toLower . stripCookExt . T.unpack