Initial commit, blog system works
This commit is contained in:
306
.lua/routes_public.lua
Normal file
306
.lua/routes_public.lua
Normal file
@@ -0,0 +1,306 @@
|
||||
-- routes_public.lua — visitor-facing routes.
|
||||
--
|
||||
-- The front page (/) is the article feed: published blog posts, newest
|
||||
-- first, paginated. /blog/:slug remains the permalink format. Wiki
|
||||
-- routes exist only when the feature_wiki flag is on.
|
||||
--
|
||||
-- Registration order matters: */search before */:slug. The catch-all
|
||||
-- 404 is registered by app.lua after all modules.
|
||||
|
||||
local fm = require "fm"
|
||||
local db = require "db"
|
||||
local render = require "render"
|
||||
local md = require "md"
|
||||
local auth = require "auth"
|
||||
local web = require "web"
|
||||
local ui = require "ui"
|
||||
local config = require "config"
|
||||
local media = require "media"
|
||||
local feed = require "feed"
|
||||
|
||||
local route = web.route
|
||||
|
||||
-- ── Shared detail-page body ───────────────────────────────────────────
|
||||
|
||||
local function detail_body(post, kind, back_href, back_label)
|
||||
local date = web.fmt_date(post.created_at)
|
||||
local updated = web.fmt_date(post.updated_at)
|
||||
local date_str = date
|
||||
if updated ~= date then
|
||||
date_str = date_str .. ' <span style="opacity:.6">(updated ' .. updated .. ')</span>'
|
||||
end
|
||||
if post.published == 0 then
|
||||
date_str = date_str .. ' <span class="status-badge status-draft">Draft</span>'
|
||||
end
|
||||
|
||||
local tags_list = web.split_tags(post.tags or "")
|
||||
local tags_html = ""
|
||||
if #tags_list > 0 then
|
||||
-- Plain links on detail pages — no #post-list in the DOM to swap into.
|
||||
tags_html = '<div class="post-meta-tags">'
|
||||
.. ui.tag_pills(tags_list, kind, false) .. '</div>'
|
||||
end
|
||||
|
||||
-- Edit affordance for a logged-in admin.
|
||||
local edit_bar = ""
|
||||
if auth.has_session() then
|
||||
edit_bar = '<div class="admin-edit-bar">'
|
||||
.. '<a href="/edit/' .. post.id .. '" class="admin-edit-link">Edit this page</a>'
|
||||
.. '</div>'
|
||||
end
|
||||
|
||||
return '<article class="post-detail">'
|
||||
.. '<header class="post-header">'
|
||||
.. '<a href="' .. back_href .. '" class="back-link">← ' .. back_label .. '</a>'
|
||||
.. '<h1>' .. EscapeHtml(post.title) .. '</h1>'
|
||||
.. '<div class="post-meta"><time datetime="' .. date .. '">' .. date_str .. '</time></div>'
|
||||
.. tags_html
|
||||
.. '</header>'
|
||||
.. '<div class="post-body">' .. md.render(post.body) .. '</div>'
|
||||
.. edit_bar
|
||||
.. '</article>'
|
||||
end
|
||||
|
||||
-- ── Front page: the article feed ──────────────────────────────────────
|
||||
|
||||
route(fm.GET "/", function(r)
|
||||
local tag = r.params.tag
|
||||
local tagline = config.get("site_tagline")
|
||||
local header = tagline ~= ""
|
||||
and ('<div class="section-header"><p>' .. EscapeHtml(tagline) .. '</p></div>')
|
||||
or ""
|
||||
|
||||
local list_html, pagination_html
|
||||
if tag then
|
||||
-- Tag filter: unpaginated result list, like a search.
|
||||
list_html = ui.post_list(db.search(nil, "blog", tag), "blog")
|
||||
pagination_html = ""
|
||||
else
|
||||
local per = math.max(1, config.get_number("posts_per_page"))
|
||||
local page = math.max(1, math.floor(tonumber(r.params.page) or 1))
|
||||
local total = db.count_published("blog")
|
||||
local pages = math.max(1, math.ceil(total / per))
|
||||
if page > pages then page = pages end
|
||||
local posts = db.list_published_page("blog", per, (page - 1) * per)
|
||||
list_html = ui.post_list(posts, "blog")
|
||||
pagination_html = ui.pagination("/", page, pages)
|
||||
end
|
||||
|
||||
local body = header
|
||||
.. ui.tag_cloud(db.list_tags("blog"), "blog", tag)
|
||||
.. list_html
|
||||
.. pagination_html
|
||||
return render.respond(config.get("site_title"), body)
|
||||
end)
|
||||
|
||||
-- Old section index → the feed now lives at /.
|
||||
route(fm.GET "/blog", function(r)
|
||||
local tag = r.params.tag
|
||||
return web.redirect(tag and ("/?tag=" .. EscapePath(tag)) or "/")
|
||||
end)
|
||||
|
||||
-- Tag-filter fragment for the feed's tag pills (HTMX swaps #post-list).
|
||||
-- Must be registered before /blog/:slug.
|
||||
route(fm.GET "/blog/search", function(r)
|
||||
local tag = r.params.tag
|
||||
if tag then
|
||||
return render.partial(
|
||||
ui.search_results(db.search(nil, "blog", tag), "blog", "tag: " .. tag))
|
||||
end
|
||||
return render.partial(ui.post_list(db.list_published("blog"), "blog"))
|
||||
end)
|
||||
|
||||
route(fm.GET "/blog/:slug", function(r)
|
||||
local post = db.get_content("blog", r.params.slug, auth.has_session())
|
||||
if not post then return web.not_found() end
|
||||
return render.respond(post.title, detail_body(post, "blog", "/", "Home"))
|
||||
end)
|
||||
|
||||
-- ── /links — single free-form page (markdown stored in settings) ─────
|
||||
|
||||
route(fm.GET "/links", function(r)
|
||||
local body_md = config.get("links_page")
|
||||
if body_md == "" then return web.not_found() end
|
||||
|
||||
local edit_bar = ""
|
||||
if auth.has_session() then
|
||||
edit_bar = '<div class="admin-edit-bar">'
|
||||
.. '<a href="/edit/links" class="admin-edit-link">Edit this page</a>'
|
||||
.. '</div>'
|
||||
end
|
||||
|
||||
local body = '<article class="post-detail">'
|
||||
.. '<header class="post-header"><h1>Links</h1></header>'
|
||||
.. '<div class="post-body">' .. md.render(body_md) .. '</div>'
|
||||
.. edit_bar
|
||||
.. '</article>'
|
||||
return render.respond("Links", body)
|
||||
end)
|
||||
|
||||
-- ── /radio/channels — lazy channel list for the radio player ─────────
|
||||
-- Fetches the configured m3u playlist server-side (browser CSP allows
|
||||
-- remote audio only, not remote fetches) and returns channel buttons.
|
||||
|
||||
local function parse_m3u(text)
|
||||
local channels = {}
|
||||
local pending_name
|
||||
for line in (text .. "\n"):gmatch("([^\n]*)\n") do
|
||||
line = line:gsub("\r$", ""):match("^%s*(.-)%s*$")
|
||||
if line:match("^#EXTINF") then
|
||||
pending_name = line:match("^#EXTINF:[^,]*,%s*(.+)$")
|
||||
elseif line ~= "" and not line:match("^#") then
|
||||
if line:match("^https?://") then
|
||||
local name = pending_name
|
||||
if not name or name == "" then
|
||||
name = line:match("([^/]+)/*$") or line
|
||||
end
|
||||
channels[#channels + 1] = { name = name, url = line }
|
||||
if #channels >= 32 then break end
|
||||
end
|
||||
pending_name = nil
|
||||
end
|
||||
end
|
||||
return channels
|
||||
end
|
||||
|
||||
route(fm.GET "/radio/channels", function(r)
|
||||
local url = config.get("radio_url")
|
||||
if not config.enabled("radio_enabled") or url == "" then
|
||||
return web.not_found()
|
||||
end
|
||||
|
||||
local ok, status, _, playlist = pcall(Fetch, url)
|
||||
if not ok or type(status) ~= "number" or status ~= 200 or not playlist then
|
||||
Log(kLogWarn, "radio playlist fetch failed: " .. tostring(ok and status or status))
|
||||
return render.partial('<span class="radio-loading">Station unreachable.</span>')
|
||||
end
|
||||
|
||||
local channels = parse_m3u(playlist)
|
||||
if #channels == 0 then
|
||||
return render.partial('<span class="radio-loading">No channels in playlist.</span>')
|
||||
end
|
||||
|
||||
local out = {}
|
||||
for _, ch in ipairs(channels) do
|
||||
out[#out + 1] = '<button type="button" class="radio-ch"'
|
||||
.. ' data-url="' .. EscapeHtml(ch.url) .. '">'
|
||||
.. EscapeHtml(ch.name) .. '</button>'
|
||||
end
|
||||
return render.partial(table.concat(out, "\n"))
|
||||
end)
|
||||
|
||||
-- ── Atom feed ─────────────────────────────────────────────────────────
|
||||
|
||||
route(fm.GET "/atom.xml", function(r)
|
||||
return feed.atom(), {ContentType = "application/atom+xml; charset=utf-8"}
|
||||
end)
|
||||
|
||||
-- ── Wiki (optional feature; parked unless feature_wiki is on) ─────────
|
||||
|
||||
local function wiki_enabled()
|
||||
return config.enabled("feature_wiki")
|
||||
end
|
||||
|
||||
route(fm.GET "/wiki", function(r)
|
||||
if not wiki_enabled() then return web.not_found() end
|
||||
local tag = r.params.tag
|
||||
local pages = tag and db.search(nil, "wiki", tag) or db.list_published("wiki")
|
||||
local body = '<div class="section-header">'
|
||||
.. '<h1>Wiki</h1>'
|
||||
.. '<p>Notes and reference pages.</p>'
|
||||
.. '</div>'
|
||||
.. ui.tag_cloud(db.list_tags("wiki"), "wiki", tag)
|
||||
.. ui.post_list(pages, "wiki")
|
||||
return render.respond("Wiki", body)
|
||||
end)
|
||||
|
||||
-- Tag-filter fragment for the wiki's tag pills (HTMX swaps #post-list).
|
||||
-- Must be registered before /wiki/:slug.
|
||||
route(fm.GET "/wiki/search", function(r)
|
||||
if not wiki_enabled() then return web.not_found() end
|
||||
local tag = r.params.tag
|
||||
if tag then
|
||||
return render.partial(
|
||||
ui.search_results(db.search(nil, "wiki", tag), "wiki", "tag: " .. tag))
|
||||
end
|
||||
return render.partial(ui.post_list(db.list_published("wiki"), "wiki"))
|
||||
end)
|
||||
|
||||
route(fm.GET "/wiki/:slug", function(r)
|
||||
if not wiki_enabled() then return web.not_found() end
|
||||
local page = db.get_content("wiki", r.params.slug, auth.has_session())
|
||||
if not page then return web.not_found() end
|
||||
return render.respond(page.title, detail_body(page, "wiki", "/wiki", "Wiki"))
|
||||
end)
|
||||
|
||||
-- ── Global search ─────────────────────────────────────────────────────
|
||||
|
||||
-- The nav search bar is the site's only search input: it hx-gets this
|
||||
-- route into #content as you type (and submits here as a plain form).
|
||||
route(fm.GET "/search", function(r)
|
||||
local q = r.params.q or ""
|
||||
-- With the wiki parked there is only one section to search.
|
||||
local only_kind = wiki_enabled() and nil or "blog"
|
||||
|
||||
local results_html
|
||||
if q == "" then
|
||||
results_html = '<div id="search-results">'
|
||||
.. '<div class="no-results">Type in the search bar above to search all content.</div>'
|
||||
.. '</div>'
|
||||
else
|
||||
local results = db.search(q, only_kind, nil)
|
||||
if #results == 0 then
|
||||
results_html = '<div id="search-results">'
|
||||
.. '<div class="no-results">No results for “' .. EscapeHtml(q) .. '”.</div>'
|
||||
.. '</div>'
|
||||
else
|
||||
local by_kind = { blog = {}, wiki = {} }
|
||||
for _, item in ipairs(results) do
|
||||
local k = item.kind
|
||||
if by_kind[k] then by_kind[k][#by_kind[k] + 1] = item end
|
||||
end
|
||||
local groups = {}
|
||||
for _, kind in ipairs({ "blog", "wiki" }) do
|
||||
if #by_kind[kind] > 0 then
|
||||
local cards = {}
|
||||
for _, post in ipairs(by_kind[kind]) do
|
||||
cards[#cards + 1] = ui.post_card(post, kind)
|
||||
end
|
||||
groups[#groups + 1] = '<div class="search-group">'
|
||||
.. '<div class="search-group-label">' .. kind .. '</div>'
|
||||
.. table.concat(cards, "\n")
|
||||
.. '</div>'
|
||||
end
|
||||
end
|
||||
results_html = '<div id="search-results">' .. table.concat(groups, "\n") .. '</div>'
|
||||
end
|
||||
end
|
||||
|
||||
local body = '<div class="section-header">'
|
||||
.. '<h1>Search</h1>'
|
||||
.. (q ~= "" and ('<p>Results for “' .. EscapeHtml(q) .. '”</p>') or '')
|
||||
.. '</div>'
|
||||
.. results_html
|
||||
|
||||
return render.respond("Search", body)
|
||||
end)
|
||||
|
||||
-- ── Docs: binary file server ──────────────────────────────────────────
|
||||
-- Docs entries are not listed in the nav or search — they are linked
|
||||
-- directly (this also serves images uploaded from the post editor).
|
||||
|
||||
route(fm.GET "/docs/:slug", function(r)
|
||||
local doc = db.get_doc(r.params.slug, auth.has_session())
|
||||
if not doc or not doc.file_data or doc.file_data == "" then
|
||||
return web.not_found()
|
||||
end
|
||||
local mime = (doc.mime_type ~= "" and doc.mime_type) or "application/octet-stream"
|
||||
local ext = media.MIME_EXT[mime] or ""
|
||||
-- Content-Disposition is set directly; Content-Type is passed as the second
|
||||
-- return value so Fullmoon uses it verbatim and skips detectType() sniffing.
|
||||
SetHeader("Content-Disposition", 'inline; filename="' .. doc.slug .. ext .. '"')
|
||||
return doc.file_data, {ContentType = mime}
|
||||
end)
|
||||
|
||||
-- Static files (serves /static/* assets from the zip)
|
||||
route(fm.GET "/static/*", "/static/*")
|
||||
Reference in New Issue
Block a user