Add What's New changelog page

- ChangelogParser: parses priv/static/changelog.txt into structured entries
- ChangelogLive: timeline view with DaisyUI styling
- Route at /changelog in authenticated scope
- What's New link in org dropdown menu
This commit is contained in:
Graham McIntire 2026-02-13 19:01:19 -06:00
parent f64a1690fd
commit 608bad0cf6
4 changed files with 93 additions and 0 deletions

View file

@ -0,0 +1,48 @@
defmodule ToweropsWeb.ChangelogParser do
@moduledoc "Parses priv/static/changelog.txt into structured entries."
@doc "Returns a list of %{date: Date.t(), title: String.t(), items: [String.t()]}"
def parse do
path = Application.app_dir(:towerops, "priv/static/changelog.txt")
case File.read(path) do
{:ok, content} -> parse_content(content)
{:error, _} -> []
end
end
defp parse_content(content) do
content
|> String.split("\n")
|> Enum.reduce([], fn line, acc ->
cond do
# Date header with title: "2026-02-13 — Title"
Regex.match?(~r/^\d{4}-\d{2}-\d{2}\s*[—–-]\s*.+/, line) ->
[date_str, title] = String.split(line, ~r/\s*[—–-]\s*/, parts: 2)
entry = %{date: Date.from_iso8601!(String.trim(date_str)), title: String.trim(title), items: []}
[entry | acc]
# Date header without title: "2026-02-13"
Regex.match?(~r/^\d{4}-\d{2}-\d{2}\s*$/, line) ->
date = Date.from_iso8601!(String.trim(line))
entry = %{date: date, title: nil, items: []}
[entry | acc]
# Bullet point
String.starts_with?(String.trim(line), "* ") ->
case acc do
[current | rest] ->
item = String.trim(line) |> String.trim_leading("* ")
[%{current | items: current.items ++ [item]} | rest]
[] ->
acc
end
true ->
acc
end
end)
|> Enum.reverse()
end
end

View file

@ -366,6 +366,14 @@ defmodule ToweropsWeb.Layouts do
>
My Data
</.link>
<.link
role="menuitem"
navigate={~p"/changelog"}
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
phx-click={JS.hide(to: "#org-menu")}
>
🆕 What's New
</.link>
<.link
role="menuitem"
href={~p"/users/log-out"}

View file

@ -0,0 +1,34 @@
defmodule ToweropsWeb.ChangelogLive do
use ToweropsWeb, :live_view
@impl true
def mount(_params, _session, socket) do
entries = ToweropsWeb.ChangelogParser.parse()
{:ok, assign(socket, entries: entries, page_title: "What's New", active_page: "changelog")}
end
@impl true
def render(assigns) do
~H"""
<div class="mx-auto max-w-3xl px-4 py-8">
<h1 class="text-3xl font-bold mb-2">What's New</h1>
<p class="text-base-content/60 mb-8">Latest updates and improvements to TowerOps.</p>
<div class="space-y-8">
<div :for={entry <- @entries} class="relative pl-8 border-l-2 border-primary/20">
<div class="absolute -left-[9px] top-1 w-4 h-4 rounded-full bg-primary border-2 border-base-100" />
<div class="mb-1">
<span class="badge badge-ghost badge-sm font-mono">
{Calendar.strftime(entry.date, "%b %d, %Y")}
</span>
</div>
<h2 :if={entry.title} class="text-lg font-semibold mb-2">{entry.title}</h2>
<ul :if={entry.items != []} class="list-disc list-inside space-y-1 text-base-content/80 text-sm">
<li :for={item <- entry.items}>{item}</li>
</ul>
</div>
</div>
</div>
"""
end
end

View file

@ -371,6 +371,9 @@ defmodule ToweropsWeb.Router do
# Activity feed
live "/activity", ActivityFeedLive, :index
# Changelog
live "/changelog", ChangelogLive, :index
# Network map route
live "/network-map", NetworkMapLive, :index
end