Files
btmx/.lua/ui.lua

146 lines
6.0 KiB
Lua

-- ui.lua — shared HTML component builders.
--
-- Pure functions: data in, HTML string out. No DB access here; route
-- handlers query and pass rows in.
local web = require "web"
local M = {}
-- ── Tag pills ─────────────────────────────────────────────────────────
-- Render tag pills for a list of tag strings.
-- kind scopes the filter link to that section ("blog" filters on the feed at /).
-- htmx=true → include hx-get/hx-target/hx-swap for list pages (where #post-list exists)
-- htmx=false → plain href links only, for detail pages (no #post-list in DOM)
function M.tag_pills(tags_list, kind, htmx)
if #tags_list == 0 then return "" end
-- Blog tag filters live on the front-page feed; wiki keeps its section page.
local base = (kind == "blog") and "/" or ("/" .. kind)
local search_path = "/" .. kind .. "/search"
local sep = (base == "/") and "?" or "?"
local pills = {}
for _, tag in ipairs(tags_list) do
local pill = '<a class="tag-pill"'
.. ' href="' .. base .. sep .. 'tag=' .. EscapePath(tag) .. '"'
if htmx then
pill = pill
.. ' hx-get="' .. search_path .. '?tag=' .. EscapePath(tag) .. '"'
.. ' hx-target="#post-list"'
.. ' hx-swap="outerHTML"'
end
pill = pill .. '>' .. EscapeHtml(tag) .. '</a>'
pills[#pills+1] = pill
end
return table.concat(pills, " ")
end
-- Render the tag cloud for a list page (with active-tag state).
-- tags_list is the output of db.list_tags(kind).
function M.tag_cloud(tags_list, kind, active_tag)
if #tags_list == 0 then return "" end
local base = (kind == "blog") and "/" or ("/" .. kind)
local search_path = "/" .. kind .. "/search"
local pills = {}
for _, t in ipairs(tags_list) do
local is_active = t.tag == active_tag
local cls = is_active and "tag-pill tag-pill-active" or "tag-pill"
-- Active tag: clicking clears the filter.
local qs = "tag=" .. EscapePath(t.tag)
local href = is_active and base or (base .. "?" .. qs)
local hx_get = is_active and search_path or (search_path .. "?" .. qs)
pills[#pills+1] = '<a class="' .. cls .. '"'
.. ' href="' .. href .. '"'
.. ' hx-get="' .. hx_get .. '"'
.. ' hx-target="#post-list"'
.. ' hx-swap="outerHTML">'
.. EscapeHtml(t.tag) .. '</a>'
end
return '<div class="tag-cloud">' .. table.concat(pills, "\n") .. '</div>'
end
-- ── Post cards / lists ────────────────────────────────────────────────
--- Render a single post/wiki card for use in list and search results.
function M.post_card(post, kind)
local date = web.fmt_date(post.created_at)
local summary = (post.summary ~= "" and post.summary)
or (post.excerpt and post.excerpt ~= "" and post.excerpt)
or post.body:sub(1, 160) .. ""
local tags_list = web.split_tags(post.tags or "")
local tags_html = ""
if #tags_list > 0 then
tags_html = '<div class="post-card-tags">'
.. M.tag_pills(tags_list, kind, true) .. '</div>'
end
local draft_html = post.published == 0
and ' <span class="status-badge status-draft">Draft</span>' or ""
return '<article class="post-card">'
.. '<a href="/' .. kind .. '/' .. EscapePath(post.slug) .. '">'
.. '<h2>' .. EscapeHtml(post.title) .. '</h2>'
.. '<div class="post-meta"><time datetime="' .. date .. '">' .. date .. '</time>'
.. draft_html .. '</div>'
.. '<p class="post-excerpt">' .. EscapeHtml(summary) .. '</p>'
.. '</a>'
.. tags_html
.. '</article>'
end
--- Render the swappable list div containing post cards.
--- kind sets a CSS modifier class (.post-list-wiki gets a 2-col grid).
function M.post_list(posts, kind)
if #posts == 0 then
return '<div id="post-list" class="post-list post-list-' .. kind
.. '"><div class="no-results">No posts found.</div></div>'
end
local cards = {}
for _, post in ipairs(posts) do
cards[#cards + 1] = M.post_card(post, kind)
end
return '<div id="post-list" class="post-list post-list-' .. kind .. '">'
.. table.concat(cards, "\n") .. '</div>'
end
--- Render search results fragment (no layout wrapper — HTMX target).
--- results: rows from db.search(). label: human-readable query description.
function M.search_results(results, kind, label)
if #results == 0 then
return '<div id="post-list" class="post-list post-list-' .. (kind or "blog")
.. '"><div class="no-results">No results for &#8220;'
.. EscapeHtml(label) .. '&#8221;.</div></div>'
end
local cards = {}
for _, post in ipairs(results) do
cards[#cards + 1] = M.post_card(post, kind or post.kind)
end
return '<div id="post-list" class="post-list post-list-' .. (kind or "blog") .. '">'
.. table.concat(cards, "\n") .. '</div>'
end
-- ── Pagination ────────────────────────────────────────────────────────
--- Render older/newer pagination links for the feed.
--- base is the page path ("/"); page numbers are 1-based.
function M.pagination(base, page, total_pages)
if total_pages <= 1 then return "" end
local sep = base:find("?", 1, true) and "&" or "?"
local function link(p, label, rel)
local href = p == 1 and base or (base .. sep .. "page=" .. p)
return '<a class="page-link" rel="' .. rel .. '" href="' .. href .. '">'
.. label .. '</a>'
end
local newer = page > 1
and link(page - 1, "&#8592; Newer", "prev")
or '<span class="page-link page-link-disabled">&#8592; Newer</span>'
local older = page < total_pages
and link(page + 1, "Older &#8594;", "next")
or '<span class="page-link page-link-disabled">Older &#8594;</span>'
return '<nav class="pagination" aria-label="Pagination">'
.. newer
.. '<span class="page-current">Page ' .. page .. ' of ' .. total_pages .. '</span>'
.. older
.. '</nav>'
end
return M