Add LiveView editor dashboard with drafts and scheduled tabs
This commit is contained in:
@@ -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
|
||||
@@ -67,6 +67,11 @@ defmodule FirehoseWeb.Router do
|
||||
scope "/", FirehoseWeb do
|
||||
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
|
||||
put "/users/settings", UserSettingsController, :update
|
||||
get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
|
||||
|
||||
@@ -216,4 +216,41 @@ defmodule FirehoseWeb.UserAuth do
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user