Add LiveView editor dashboard with drafts and scheduled tabs

This commit is contained in:
Willem van den Ende 2026-04-01 21:42:45 +00:00
parent 5395b2de80
commit f1560ff0e7
6 changed files with 309 additions and 1 deletions

View File

@ -8,5 +8,5 @@
{"id":"firehose-apw","title":"Add integration tests for scheduled post filtering in Phoenix","description":"## Context\nPhoenix blog controller tests need to verify date filtering works end-to-end.\nMay need a far-future markdown test fixture (2099/01-01-future-post.md).\n\n## Scope\n- app/test/firehose_web/controllers/blog_test.exs\n- app/priv/blog/engineering/2099/01-01-future-post.md (test fixture)\n\n## TDD\nRED: Blog index hides future post, show page returns it, tag page excludes it\nGREEN: Should pass from Blogex changes\nREFACTOR: None","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.294363414Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:35:39.95804435Z","dependencies":[{"issue_id":"firehose-apw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.797645635Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-apw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.829112074Z","created_by":"Willem van den Ende"}]} {"id":"firehose-apw","title":"Add integration tests for scheduled post filtering in Phoenix","description":"## Context\nPhoenix blog controller tests need to verify date filtering works end-to-end.\nMay need a far-future markdown test fixture (2099/01-01-future-post.md).\n\n## Scope\n- app/test/firehose_web/controllers/blog_test.exs\n- app/priv/blog/engineering/2099/01-01-future-post.md (test fixture)\n\n## TDD\nRED: Blog index hides future post, show page returns it, tag page excludes it\nGREEN: Should pass from Blogex changes\nREFACTOR: None","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.294363414Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:35:39.95804435Z","dependencies":[{"issue_id":"firehose-apw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.797645635Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-apw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.829112074Z","created_by":"Willem van den Ende"}]}
{"id":"firehose-dhh","title":"Run mix phx.gen.auth and configure","description":"## Context\nNo auth exists. Run mix phx.gen.auth Accounts User users.\nRemove auth links from public nav (login/registration are hidden URLs).\n\n## Scope\n- Generated files in app/lib/firehose/accounts/, app/lib/firehose_web/\n- app/lib/firehose_web/router.ex\n- Layout files (root.html.heex, app.html.heex) — remove injected auth links\n\n## TDD\nRED: Generated tests should pass\nGREEN: Run generator, migrate, verify\nREFACTOR: Remove auth links from public navigation","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.010843844Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.37861782Z","closed_at":"2026-04-01T20:31:20.37861782Z","close_reason":"Closed"} {"id":"firehose-dhh","title":"Run mix phx.gen.auth and configure","description":"## Context\nNo auth exists. Run mix phx.gen.auth Accounts User users.\nRemove auth links from public nav (login/registration are hidden URLs).\n\n## Scope\n- Generated files in app/lib/firehose/accounts/, app/lib/firehose_web/\n- app/lib/firehose_web/router.ex\n- Layout files (root.html.heex, app.html.heex) — remove injected auth links\n\n## TDD\nRED: Generated tests should pass\nGREEN: Run generator, migrate, verify\nREFACTOR: Remove auth links from public navigation","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.010843844Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.37861782Z","closed_at":"2026-04-01T20:31:20.37861782Z","close_reason":"Closed"}
{"id":"firehose-pp3","title":"Seed demo user in dev","description":"## Context\nSeed demo@example.com / password123 in dev environment only.\nUse Accounts context from phx.gen.auth.\n\n## Scope\n- app/priv/repo/seeds.exs\n\n## TDD\nTrivial — manual verification","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.091149857Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:37:09.561290121Z","closed_at":"2026-04-01T21:37:09.561290121Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-pp3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.537294098Z","created_by":"Willem van den Ende"}]} {"id":"firehose-pp3","title":"Seed demo user in dev","description":"## Context\nSeed demo@example.com / password123 in dev environment only.\nUse Accounts context from phx.gen.auth.\n\n## Scope\n- app/priv/repo/seeds.exs\n\n## TDD\nTrivial — manual verification","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.091149857Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:37:09.561290121Z","closed_at":"2026-04-01T21:37:09.561290121Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-pp3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.537294098Z","created_by":"Willem van den Ende"}]}
{"id":"firehose-ra3","title":"Show draft/scheduled status banners for authenticated users","description":"## Context\nWhen authenticated user views a draft or scheduled post via direct URL,\nshow a banner: \"Draft — not published\" or \"This post is scheduled for {date}\".\nUnauthenticated users see no banner.\n\n## Scope\n- app/lib/firehose_web/controllers/blog_controller.ex: pass visibility to template\n- app/lib/firehose_web/controllers/blog_html/show.html.heex: conditional banner\n- app/test/firehose_web/controllers/blog_test.exs: banner tests\n\n## TDD\nRED: Auth user sees banner on draft/scheduled, no banner on live, unauth sees no banner\nGREEN: Compute visibility, pass to template, render conditionally\nREFACTOR: Extract banner component if reusable","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.713739919Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:32:40.675871251Z","dependencies":[{"issue_id":"firehose-ra3","depends_on_id":"firehose-4nq","type":"blocks","created_at":"2026-04-01T20:08:01.660225195Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-ra3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.696919105Z","created_by":"Willem van den Ende"}]} {"id":"firehose-ra3","title":"Show draft/scheduled status banners for authenticated users","description":"## Context\nWhen authenticated user views a draft or scheduled post via direct URL,\nshow a banner: \"Draft — not published\" or \"This post is scheduled for {date}\".\nUnauthenticated users see no banner.\n\n## Scope\n- app/lib/firehose_web/controllers/blog_controller.ex: pass visibility to template\n- app/lib/firehose_web/controllers/blog_html/show.html.heex: conditional banner\n- app/test/firehose_web/controllers/blog_test.exs: banner tests\n\n## TDD\nRED: Auth user sees banner on draft/scheduled, no banner on live, unauth sees no banner\nGREEN: Compute visibility, pass to template, render conditionally\nREFACTOR: Extract banner component if reusable","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.713739919Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:40:21.809364236Z","closed_at":"2026-04-01T21:40:21.809364236Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-ra3","depends_on_id":"firehose-4nq","type":"blocks","created_at":"2026-04-01T20:08:01.660225195Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-ra3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.696919105Z","created_by":"Willem van den Ende"}]}
{"id":"firehose-vyw","title":"Verify router respects date filtering","description":"## Context\nBlogex.Router index, tag, and feed routes use all_posts()/posts_by_tag() (now filtered).\nThe /:slug route uses get_post() (now unfiltered). Add tests confirming correct behaviour.\n\n## Scope\n- blogex/test/blogex/router_test.exs\n\n## TDD\nRED: Test GET / excludes future posts, GET /tag/:tag excludes, GET /:slug returns future post\nGREEN: Should pass from Steps 1-2\nREFACTOR: None","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.253169962Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:35:39.918341344Z","dependencies":[{"issue_id":"firehose-vyw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.73739353Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-vyw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.770379034Z","created_by":"Willem van den Ende"}]} {"id":"firehose-vyw","title":"Verify router respects date filtering","description":"## Context\nBlogex.Router index, tag, and feed routes use all_posts()/posts_by_tag() (now filtered).\nThe /:slug route uses get_post() (now unfiltered). Add tests confirming correct behaviour.\n\n## Scope\n- blogex/test/blogex/router_test.exs\n\n## TDD\nRED: Test GET / excludes future posts, GET /tag/:tag excludes, GET /:slug returns future post\nGREEN: Should pass from Steps 1-2\nREFACTOR: None","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.253169962Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:35:39.918341344Z","dependencies":[{"issue_id":"firehose-vyw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.73739353Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-vyw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.770379034Z","created_by":"Willem van den Ende"}]}

View File

@ -0,0 +1,113 @@
defmodule FirehoseWeb.EditorDashboardLive do
use FirehoseWeb, :live_view
alias Blogex.Post
@impl true
def mount(_params, _session, socket) do
all_posts = Blogex.Registry.all_posts_unfiltered()
drafts =
all_posts
|> Enum.filter(&(Post.visibility(&1) == :draft))
|> Enum.sort_by(& &1.date, {:desc, Date})
scheduled =
all_posts
|> Enum.filter(&(Post.visibility(&1) == :scheduled))
|> Enum.sort_by(& &1.date, {:asc, Date})
{:ok,
socket
|> assign(:page_title, "Editor Dashboard")
|> assign(:drafts, drafts)
|> assign(:scheduled, scheduled)
|> assign(:active_tab, :drafts)}
end
@impl true
def render(assigns) do
~H"""
<div class="max-w-4xl mx-auto">
<h1 class="text-2xl font-bold mb-6">Dashboard</h1>
<div class="flex gap-4 mb-6 border-b border-zinc-200">
<button
phx-click="switch_tab"
phx-value-tab="drafts"
class={[
"pb-2 px-1 text-sm font-medium transition-colors",
if(@active_tab == :drafts,
do: "border-b-2 border-zinc-900 text-zinc-900",
else: "text-zinc-500 hover:text-zinc-700"
)
]}
>
Drafts ({length(@drafts)})
</button>
<button
phx-click="switch_tab"
phx-value-tab="scheduled"
class={[
"pb-2 px-1 text-sm font-medium transition-colors",
if(@active_tab == :scheduled,
do: "border-b-2 border-zinc-900 text-zinc-900",
else: "text-zinc-500 hover:text-zinc-700"
)
]}
>
Scheduled ({length(@scheduled)})
</button>
</div>
<div id="drafts-tab" class={if(@active_tab != :drafts, do: "hidden")}>
<div :if={@drafts == []} class="text-zinc-500 text-sm">No drafts</div>
<div :for={post <- @drafts} class="py-4 border-b border-zinc-100 last:border-0">
<div class="flex items-center justify-between">
<div>
<.link
navigate={post_path(post)}
class="text-base font-medium text-zinc-900 hover:underline"
>
{post.title}
</.link>
<div class="text-sm text-zinc-500 mt-1">
{post.author} · {Calendar.strftime(post.date, "%b %d, %Y")} · <span class="text-amber-600 font-medium">Draft</span>
</div>
</div>
</div>
</div>
</div>
<div id="scheduled-tab" class={if(@active_tab != :scheduled, do: "hidden")}>
<div :if={@scheduled == []} class="text-zinc-500 text-sm">No scheduled posts</div>
<div :for={post <- @scheduled} class="py-4 border-b border-zinc-100 last:border-0">
<div class="flex items-center justify-between">
<div>
<.link
navigate={post_path(post)}
class="text-base font-medium text-zinc-900 hover:underline"
>
{post.title}
</.link>
<div class="text-sm text-zinc-500 mt-1">
{post.author} · {Calendar.strftime(post.date, "%b %d, %Y")} · <span class="text-blue-600 font-medium">{Post.days_until_live(post)} days until live</span>
</div>
</div>
</div>
</div>
</div>
</div>
"""
end
@impl true
def handle_event("switch_tab", %{"tab" => tab}, socket) do
{:noreply, assign(socket, :active_tab, String.to_existing_atom(tab))}
end
defp post_path(post) do
blog = Blogex.Registry.get_blog!(post.blog)
"#{blog.base_path()}/#{post.id}"
end
end

View File

@ -67,6 +67,11 @@ defmodule FirehoseWeb.Router do
scope "/", FirehoseWeb do scope "/", FirehoseWeb do
pipe_through [:browser, :require_authenticated_user] pipe_through [:browser, :require_authenticated_user]
live_session :authenticated_user,
on_mount: [{FirehoseWeb.UserAuth, :ensure_authenticated}] do
live "/editor/dashboard", EditorDashboardLive
end
get "/users/settings", UserSettingsController, :edit get "/users/settings", UserSettingsController, :edit
put "/users/settings", UserSettingsController, :update put "/users/settings", UserSettingsController, :update
get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email

View File

@ -216,4 +216,41 @@ defmodule FirehoseWeb.UserAuth do
end end
defp maybe_store_return_to(conn), do: conn defp maybe_store_return_to(conn), do: conn
@doc """
LiveView on_mount callback that ensures the user is authenticated.
Used in `live_session` blocks in the router:
live_session :authenticated, on_mount: [{FirehoseWeb.UserAuth, :ensure_authenticated}] do
live "/editor/dashboard", EditorDashboardLive
end
"""
def on_mount(:ensure_authenticated, _params, session, socket) do
socket = mount_current_scope(socket, session)
if socket.assigns.current_scope && socket.assigns.current_scope.user do
{:cont, socket}
else
socket =
socket
|> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.")
|> Phoenix.LiveView.redirect(to: ~p"/users/log-in")
{:halt, socket}
end
end
defp mount_current_scope(socket, session) do
Phoenix.Component.assign_new(socket, :current_scope, fn ->
if token = session["user_token"] do
case Accounts.get_user_by_session_token(token) do
{user, _token_inserted_at} -> Scope.for_user(user)
nil -> Scope.for_user(nil)
end
else
Scope.for_user(nil)
end
end)
end
end end

View File

@ -0,0 +1,88 @@
defmodule FirehoseWeb.EditorDashboardLiveTest do
use FirehoseWeb.ConnCase, async: true
import Phoenix.LiveViewTest
setup do
posts = [
%Blogex.Post{
id: "live-post",
title: "Live Post",
author: "Test Author",
body: "<p>Body</p>",
description: "A live post",
date: ~D[2020-01-01],
published: true,
blog: :test_blog,
tags: []
},
%Blogex.Post{
id: "draft-post",
title: "Draft Post",
author: "Test Author",
body: "<p>Body</p>",
description: "A draft post",
date: ~D[2026-03-12],
published: false,
blog: :test_blog,
tags: []
},
%Blogex.Post{
id: "scheduled-post",
title: "Scheduled Post",
author: "Test Author",
body: "<p>Body</p>",
description: "A scheduled post",
date: ~D[2099-06-15],
published: true,
blog: :test_blog,
tags: ["future"]
}
]
{:ok, _} =
Firehose.Test.FakeBlog.start(posts,
blog_id: :test_blog,
title: "Test Blog",
base_path: "/blog/test"
)
Application.put_env(:blogex, :blogs, [Firehose.Test.FakeBlog])
on_exit(fn -> Application.delete_env(:blogex, :blogs) end)
:ok
end
describe "unauthenticated" do
test "redirects to login", %{conn: conn} do
assert {:error, redirect} = live(conn, ~p"/editor/dashboard")
assert {:redirect, %{to: to}} = redirect
assert to =~ "/users/log-in"
end
end
describe "authenticated" do
setup :register_and_log_in_user
test "renders the dashboard", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/editor/dashboard")
assert html =~ "Dashboard"
end
test "shows draft posts", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/editor/dashboard")
assert html =~ "Draft Post"
end
test "shows scheduled posts with days until live", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/editor/dashboard")
assert html =~ "Scheduled Post"
assert html =~ "days"
end
test "does not show live posts", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/editor/dashboard")
refute html =~ "Live Post"
end
end
end

View File

@ -0,0 +1,65 @@
defmodule Firehose.Test.FakeBlog do
@moduledoc """
A test double that implements the blog module interface,
backed by an Agent so tests can control the post data.
"""
use Agent
@defaults [
blog_id: :test_blog,
title: "Test Blog",
description: "A blog for tests",
base_path: "/blog/test"
]
def start(posts \\ [], opts \\ []) do
opts = Keyword.merge(@defaults, opts)
state = %{
posts: posts,
blog_id: opts[:blog_id],
title: opts[:title],
description: opts[:description],
base_path: opts[:base_path]
}
case Agent.start(fn -> state end, name: __MODULE__) do
{:ok, pid} ->
{:ok, pid}
{:error, {:already_started, pid}} ->
Agent.update(__MODULE__, fn _ -> state end)
{:ok, pid}
end
end
defp get(key), do: Agent.get(__MODULE__, &Map.fetch!(&1, key))
def blog_id, do: get(:blog_id)
def title, do: get(:title)
def description, do: get(:description)
def base_path, do: get(:base_path)
def all_posts_unfiltered do
get(:posts)
|> Enum.sort_by(& &1.date, {:desc, Date})
end
def unfiltered_posts do
all_posts_unfiltered()
end
def all_posts do
get(:posts)
|> Enum.filter(& &1.published)
|> Enum.sort_by(& &1.date, {:desc, Date})
end
def all_tags do
all_posts()
|> Enum.flat_map(& &1.tags)
|> Enum.uniq()
|> Enum.sort()
end
end