diff --git a/src/Roux/Html.hs b/src/Roux/Html.hs index f7c4a9e..c09601b 100644 --- a/src/Roux/Html.hs +++ b/src/Roux/Html.hs @@ -208,121 +208,122 @@ page title content = -- | 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 -- setting hash triggers hashchange" - , "if (!location.hash) location.hash = 'alpha';" - , "else { updateActiveNav(); render(); }" - , "" - , "})();" - ] +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 -- setting hash triggers hashchange" + , "if (!location.hash) location.hash = 'alpha';" + , "else { updateActiveNav(); render(); }" + , "" + , "})();" + ] -- | Render the recipe index page -- shell with embedded JSON + JS rendering. indexPage :: SortMode -> [Idx.RecipeInfo] -> ByteString @@ -349,7 +350,7 @@ indexPage _mode recipes = 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 + H.script ! A.type_ "text/javascript" $ H.preEscapedText searchJs where filterOk = filter (isRight . Idx.riRecipe) filterBad = filter (isLeft . Idx.riRecipe) @@ -634,5 +635,3 @@ isLeft _ = False -- | Check if an 'Either' is 'Right'. isRight :: Either a b -> Bool isRight = not . isLeft - -