feat: first take at UI app
This commit is contained in:
16
lib/bds/ui/commands.ex
Normal file
16
lib/bds/ui/commands.ex
Normal file
@@ -0,0 +1,16 @@
|
||||
defmodule BDS.UI.Commands do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.UI.MenuBar
|
||||
|
||||
def handle_shortcut(state, shortcut) when is_map(shortcut) do
|
||||
key = shortcut |> Map.get(:key, Map.get(shortcut, "key", "")) |> String.downcase()
|
||||
primary = Map.get(shortcut, :meta, false) or Map.get(shortcut, :ctrl, false)
|
||||
|
||||
cond do
|
||||
primary and key == "b" -> MenuBar.execute(state, :toggle_sidebar)
|
||||
primary and key == "w" -> MenuBar.execute(state, :close_tab)
|
||||
true -> state
|
||||
end
|
||||
end
|
||||
end
|
||||
41
lib/bds/ui/menu_bar.ex
Normal file
41
lib/bds/ui/menu_bar.ex
Normal file
@@ -0,0 +1,41 @@
|
||||
defmodule BDS.UI.MenuBar do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.UI.Workbench
|
||||
|
||||
def default_groups(opts \\ []) do
|
||||
dev_mode? = Keyword.get(opts, :dev_mode?, false)
|
||||
|
||||
[
|
||||
%{id: :app, items: [%{id: :about}, %{id: :settings}]},
|
||||
%{id: :file, items: [%{id: :new_post}, %{id: :new_page}, %{id: :close_tab}]},
|
||||
%{id: :edit, items: [%{id: :undo}, %{id: :redo}]},
|
||||
%{id: :view, items: view_items(dev_mode?)},
|
||||
%{id: :window, items: [%{id: :minimize}, %{id: :zoom}]},
|
||||
%{id: :help, items: [%{id: :documentation}, %{id: :api_documentation}]}
|
||||
]
|
||||
end
|
||||
|
||||
def execute(state, :toggle_sidebar), do: Workbench.toggle_sidebar(state)
|
||||
def execute(state, :toggle_panel), do: Workbench.toggle_panel(state)
|
||||
def execute(state, :toggle_assistant_sidebar), do: Workbench.toggle_assistant_sidebar(state)
|
||||
|
||||
def execute(state, :close_tab) do
|
||||
case state.active_tab do
|
||||
{type, id} -> Workbench.close_tab(state, type, id)
|
||||
nil -> state
|
||||
end
|
||||
end
|
||||
|
||||
def execute(state, _command_id), do: state
|
||||
|
||||
defp view_items(dev_mode?) do
|
||||
base = [
|
||||
%{id: :toggle_sidebar},
|
||||
%{id: :toggle_panel},
|
||||
%{id: :toggle_assistant_sidebar}
|
||||
]
|
||||
|
||||
if dev_mode?, do: base ++ [%{id: :toggle_dev_tools}], else: base
|
||||
end
|
||||
end
|
||||
44
lib/bds/ui/registry.ex
Normal file
44
lib/bds/ui/registry.ex
Normal file
@@ -0,0 +1,44 @@
|
||||
defmodule BDS.UI.Registry do
|
||||
@moduledoc false
|
||||
|
||||
@sidebar_views [
|
||||
%{id: :posts, label: "Posts", activity_group: :top, editor_route: :post, entity_tab: true, demo_kind: :entity},
|
||||
%{id: :pages, label: "Pages", activity_group: :top, editor_route: :post, entity_tab: true, demo_kind: :entity},
|
||||
%{id: :media, label: "Media", activity_group: :top, editor_route: :media, entity_tab: true, demo_kind: :entity},
|
||||
%{id: :scripts, label: "Scripts", activity_group: :top, editor_route: :scripts, entity_tab: true, demo_kind: :entity},
|
||||
%{id: :templates, label: "Templates", activity_group: :top, editor_route: :templates, entity_tab: true, demo_kind: :entity},
|
||||
%{id: :tags, label: "Tags", activity_group: :top, editor_route: :tags, singleton: true, demo_kind: :singleton},
|
||||
%{id: :chat, label: "AI Assistant", activity_group: :top, editor_route: :chat, entity_tab: true, demo_kind: :entity},
|
||||
%{id: :import, label: "Import", activity_group: :top, editor_route: :import, entity_tab: true, demo_kind: :entity},
|
||||
%{id: :git, label: "Source Control", activity_group: :bottom, editor_route: :git_diff, entity_tab: true, demo_kind: :entity},
|
||||
%{id: :settings, label: "Settings", activity_group: :bottom, editor_route: :settings, singleton: true, demo_kind: :singleton}
|
||||
]
|
||||
|
||||
@editor_routes [
|
||||
%{id: :dashboard, singleton: true, entity_tab: false, title: "Dashboard"},
|
||||
%{id: :post, singleton: false, entity_tab: true, title: "Post"},
|
||||
%{id: :media, singleton: false, entity_tab: true, title: "Media"},
|
||||
%{id: :settings, singleton: true, entity_tab: false, title: "Settings"},
|
||||
%{id: :style, singleton: true, entity_tab: false, title: "Style"},
|
||||
%{id: :tags, singleton: true, entity_tab: false, title: "Tags"},
|
||||
%{id: :chat, singleton: false, entity_tab: true, title: "Chat"},
|
||||
%{id: :import, singleton: false, entity_tab: true, title: "Import"},
|
||||
%{id: :menu_editor, singleton: true, entity_tab: false, title: "Menu"},
|
||||
%{id: :metadata_diff, singleton: true, entity_tab: false, title: "Metadata Diff"},
|
||||
%{id: :git_diff, singleton: false, entity_tab: true, title: "Git Diff"},
|
||||
%{id: :documentation, singleton: true, entity_tab: false, title: "Documentation"},
|
||||
%{id: :api_documentation, singleton: true, entity_tab: false, title: "API"},
|
||||
%{id: :site_validation, singleton: true, entity_tab: false, title: "Site Validation"},
|
||||
%{id: :translation_validation, singleton: true, entity_tab: false, title: "Translations"},
|
||||
%{id: :scripts, singleton: false, entity_tab: true, title: "Script"},
|
||||
%{id: :templates, singleton: false, entity_tab: true, title: "Template"},
|
||||
%{id: :find_duplicates, singleton: true, entity_tab: false, title: "Find Duplicates"}
|
||||
]
|
||||
|
||||
def default_sidebar_view, do: :posts
|
||||
def sidebar_views, do: @sidebar_views
|
||||
def editor_routes, do: @editor_routes
|
||||
|
||||
def sidebar_view(id) when is_atom(id), do: Enum.find(@sidebar_views, &(&1.id == id))
|
||||
def editor_route(id) when is_atom(id), do: Enum.find(@editor_routes, &(&1.id == id))
|
||||
end
|
||||
68
lib/bds/ui/session.ex
Normal file
68
lib/bds/ui/session.ex
Normal file
@@ -0,0 +1,68 @@
|
||||
defmodule BDS.UI.Session do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.UI.Workbench
|
||||
|
||||
def serialize(state) do
|
||||
%{
|
||||
"sidebar_visible" => state.sidebar_visible,
|
||||
"sidebar_width" => state.sidebar_width,
|
||||
"active_view" => Atom.to_string(state.active_view),
|
||||
"assistant_sidebar_visible" => state.assistant_sidebar_visible,
|
||||
"assistant_sidebar_width" => state.assistant_sidebar_width,
|
||||
"panel" => %{
|
||||
"visible" => state.panel.visible,
|
||||
"active_tab" => Atom.to_string(state.panel.active_tab)
|
||||
},
|
||||
"tabs" =>
|
||||
Enum.map(state.tabs, fn tab ->
|
||||
%{
|
||||
"type" => Atom.to_string(tab.type),
|
||||
"id" => tab.id,
|
||||
"is_transient" => tab.is_transient
|
||||
}
|
||||
end),
|
||||
"active_tab" => encode_tab_ref(state.active_tab),
|
||||
"dirty_tabs" => Enum.map(state.dirty_tabs, &encode_tab_ref/1)
|
||||
}
|
||||
end
|
||||
|
||||
def restore(payload) when is_map(payload) do
|
||||
state =
|
||||
Workbench.new(
|
||||
sidebar_visible: Map.get(payload, "sidebar_visible", true),
|
||||
sidebar_width: Map.get(payload, "sidebar_width", 280),
|
||||
active_view: Map.get(payload, "active_view", "posts"),
|
||||
assistant_sidebar_visible: Map.get(payload, "assistant_sidebar_visible", false),
|
||||
assistant_sidebar_width: Map.get(payload, "assistant_sidebar_width", 360),
|
||||
panel_visible: get_in(payload, ["panel", "visible"]) || false,
|
||||
panel_tab: atomize(get_in(payload, ["panel", "active_tab"]) || "tasks"),
|
||||
dirty_tabs: Enum.map(Map.get(payload, "dirty_tabs", []), &decode_tab_ref/1)
|
||||
)
|
||||
|
||||
tabs =
|
||||
Enum.map(Map.get(payload, "tabs", []), fn tab ->
|
||||
%{
|
||||
type: atomize(Map.get(tab, "type", "post")),
|
||||
id: Map.get(tab, "id"),
|
||||
is_transient: Map.get(tab, "is_transient", false)
|
||||
}
|
||||
end)
|
||||
|
||||
active_tab = decode_tab_ref(Map.get(payload, "active_tab"))
|
||||
|
||||
%{state | tabs: tabs, active_tab: active_tab, editor_route: active_route(active_tab)}
|
||||
end
|
||||
|
||||
defp encode_tab_ref(nil), do: nil
|
||||
defp encode_tab_ref({type, id}), do: %{"type" => Atom.to_string(type), "id" => id}
|
||||
|
||||
defp decode_tab_ref(nil), do: nil
|
||||
defp decode_tab_ref(%{"type" => type, "id" => id}), do: {atomize(type), id}
|
||||
|
||||
defp atomize(value) when is_atom(value), do: value
|
||||
defp atomize(value) when is_binary(value), do: String.to_atom(value)
|
||||
|
||||
defp active_route(nil), do: :dashboard
|
||||
defp active_route({type, _id}), do: type
|
||||
end
|
||||
62
lib/bds/ui/shell_page.ex
Normal file
62
lib/bds/ui/shell_page.ex
Normal file
@@ -0,0 +1,62 @@
|
||||
defmodule BDS.UI.ShellPage do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.UI.MenuBar
|
||||
alias BDS.UI.Registry
|
||||
alias BDS.UI.Session
|
||||
alias BDS.UI.Workbench
|
||||
|
||||
def render do
|
||||
bootstrap =
|
||||
%{
|
||||
registry: %{
|
||||
sidebar_views: Registry.sidebar_views(),
|
||||
editor_routes: Registry.editor_routes(),
|
||||
default_sidebar_view: Registry.default_sidebar_view()
|
||||
},
|
||||
menu_groups: MenuBar.default_groups(),
|
||||
session: Session.serialize(Workbench.new(panel_visible: true)),
|
||||
status: %{
|
||||
post_count: 12,
|
||||
media_count: 34,
|
||||
theme_badge: "zinc",
|
||||
ui_language: "en",
|
||||
offline_mode: true,
|
||||
running_task_message: "Building starter shell",
|
||||
running_task_overflow: 1,
|
||||
git_badge_count: 7
|
||||
}
|
||||
}
|
||||
|
||||
[
|
||||
"<!DOCTYPE html>",
|
||||
"<html lang=\"en\">",
|
||||
"<head>",
|
||||
" <meta charset=\"utf-8\">",
|
||||
" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
|
||||
" <title>bDS Shell</title>",
|
||||
" <link rel=\"stylesheet\" href=\"./app.css\">",
|
||||
"</head>",
|
||||
"<body>",
|
||||
" <div id=\"bds-shell-app\">",
|
||||
" <header data-region=\"title-bar\"></header>",
|
||||
" <div data-region=\"activity-bar\"></div>",
|
||||
" <aside data-region=\"sidebar\"></aside>",
|
||||
" <div data-role=\"resize-handle\" data-target=\"sidebar\"></div>",
|
||||
" <main data-region=\"content\">",
|
||||
" <div data-region=\"tab-bar\"></div>",
|
||||
" <section data-region=\"editor\"></section>",
|
||||
" <section data-region=\"panel\"></section>",
|
||||
" </main>",
|
||||
" <div data-role=\"resize-handle\" data-target=\"assistant\"></div>",
|
||||
" <aside data-region=\"assistant-sidebar\"></aside>",
|
||||
" <footer data-region=\"status-bar\"></footer>",
|
||||
" </div>",
|
||||
" <script id=\"bds-shell-bootstrap\" type=\"application/json\">#{Jason.encode!(bootstrap)}</script>",
|
||||
" <script src=\"./app.js\"></script>",
|
||||
"</body>",
|
||||
"</html>"
|
||||
]
|
||||
|> Enum.join("\n")
|
||||
end
|
||||
end
|
||||
268
lib/bds/ui/workbench.ex
Normal file
268
lib/bds/ui/workbench.ex
Normal file
@@ -0,0 +1,268 @@
|
||||
defmodule BDS.UI.Workbench do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.UI.Registry
|
||||
|
||||
@singleton_tabs MapSet.new([
|
||||
:settings,
|
||||
:tags,
|
||||
:style,
|
||||
:scripts,
|
||||
:menu_editor,
|
||||
:documentation,
|
||||
:api_documentation,
|
||||
:metadata_diff,
|
||||
:site_validation,
|
||||
:translation_validation,
|
||||
:find_duplicates
|
||||
])
|
||||
|
||||
defstruct sidebar_visible: true,
|
||||
sidebar_width: 280,
|
||||
active_view: :posts,
|
||||
assistant_sidebar_visible: false,
|
||||
assistant_sidebar_width: 360,
|
||||
panel: %{visible: false, active_tab: :tasks},
|
||||
tabs: [],
|
||||
active_tab: nil,
|
||||
editor_route: :dashboard,
|
||||
dirty_tabs: MapSet.new()
|
||||
|
||||
def new(opts \\ []) do
|
||||
%__MODULE__{
|
||||
sidebar_visible: Keyword.get(opts, :sidebar_visible, true),
|
||||
sidebar_width: clamp_sidebar_width(Keyword.get(opts, :sidebar_width, 280)),
|
||||
active_view: normalize_type(Keyword.get(opts, :active_view, :posts)),
|
||||
assistant_sidebar_visible: Keyword.get(opts, :assistant_sidebar_visible, false),
|
||||
assistant_sidebar_width:
|
||||
clamp_assistant_sidebar_width(Keyword.get(opts, :assistant_sidebar_width, 360)),
|
||||
panel: %{
|
||||
visible: Keyword.get(opts, :panel_visible, false),
|
||||
active_tab: Keyword.get(opts, :panel_tab, :tasks)
|
||||
},
|
||||
dirty_tabs: MapSet.new(Keyword.get(opts, :dirty_tabs, []))
|
||||
}
|
||||
|> sync_editor_route()
|
||||
|> normalize_panel()
|
||||
end
|
||||
|
||||
def set_sidebar_width(state, width) when is_integer(width) do
|
||||
%{state | sidebar_width: clamp_sidebar_width(width)}
|
||||
end
|
||||
|
||||
def set_assistant_sidebar_width(state, width) when is_integer(width) do
|
||||
%{state | assistant_sidebar_width: clamp_assistant_sidebar_width(width)}
|
||||
end
|
||||
|
||||
def open_tab(state, type, id, intent) do
|
||||
{tabs, opened_tab} = upsert_tab(state.tabs, normalize_type(type), id, intent)
|
||||
|
||||
state
|
||||
|> Map.put(:tabs, tabs)
|
||||
|> Map.put(:active_tab, tab_ref(opened_tab))
|
||||
|> sync_editor_route()
|
||||
|> normalize_panel()
|
||||
end
|
||||
|
||||
def open_tab_in_background(state, type, id, intent) do
|
||||
current_active = state.active_tab
|
||||
{tabs, _opened_tab} = upsert_tab(state.tabs, normalize_type(type), id, intent)
|
||||
|
||||
state
|
||||
|> Map.put(:tabs, tabs)
|
||||
|> Map.put(:active_tab, current_active)
|
||||
|> sync_editor_route()
|
||||
|> normalize_panel()
|
||||
end
|
||||
|
||||
def close_tab(state, type, id) do
|
||||
type = normalize_type(type)
|
||||
target = {type, id}
|
||||
index = Enum.find_index(state.tabs, &(tab_ref(&1) == target))
|
||||
|
||||
if is_nil(index) do
|
||||
state
|
||||
else
|
||||
tabs = List.delete_at(state.tabs, index)
|
||||
|
||||
next_active =
|
||||
cond do
|
||||
state.active_tab != target -> state.active_tab
|
||||
tabs == [] -> nil
|
||||
index < length(tabs) -> tab_ref(Enum.at(tabs, index))
|
||||
true -> tab_ref(List.last(tabs))
|
||||
end
|
||||
|
||||
state
|
||||
|> Map.put(:tabs, tabs)
|
||||
|> Map.put(:active_tab, next_active)
|
||||
|> sync_editor_route()
|
||||
|> normalize_panel()
|
||||
end
|
||||
end
|
||||
|
||||
def pin_tab(state, type, id) do
|
||||
type = normalize_type(type)
|
||||
|
||||
tabs =
|
||||
Enum.map(state.tabs, fn tab ->
|
||||
if tab_ref(tab) == {type, id}, do: %{tab | is_transient: false}, else: tab
|
||||
end)
|
||||
|
||||
%{state | tabs: tabs}
|
||||
end
|
||||
|
||||
def clear_tabs(state) do
|
||||
%{state | tabs: [], active_tab: nil, editor_route: :dashboard, dirty_tabs: MapSet.new()}
|
||||
|> normalize_panel()
|
||||
end
|
||||
|
||||
def mark_dirty(state, type, id) do
|
||||
if normalize_type(type) == :post do
|
||||
%{state | dirty_tabs: MapSet.put(state.dirty_tabs, {normalize_type(type), id})}
|
||||
else
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
def clear_dirty(state, type, id) do
|
||||
%{state | dirty_tabs: MapSet.delete(state.dirty_tabs, {normalize_type(type), id})}
|
||||
end
|
||||
|
||||
def dirty?(state, type, id) do
|
||||
MapSet.member?(state.dirty_tabs, {normalize_type(type), id})
|
||||
end
|
||||
|
||||
def toggle_sidebar(state), do: %{state | sidebar_visible: not state.sidebar_visible}
|
||||
|
||||
def set_panel_visible(state, visible) when is_boolean(visible) do
|
||||
%{state | panel: %{state.panel | visible: visible}}
|
||||
end
|
||||
|
||||
def toggle_panel(state) do
|
||||
set_panel_visible(state, not state.panel.visible)
|
||||
end
|
||||
|
||||
def toggle_assistant_sidebar(state) do
|
||||
%{state | assistant_sidebar_visible: not state.assistant_sidebar_visible}
|
||||
end
|
||||
|
||||
def set_panel_tab(state, tab) when tab in [:tasks, :output, :post_links, :git_log] do
|
||||
%{state | panel: %{state.panel | active_tab: tab}}
|
||||
end
|
||||
|
||||
def click_activity(state, activity_id) do
|
||||
activity_id = normalize_type(activity_id)
|
||||
|
||||
cond do
|
||||
state.active_view == activity_id -> toggle_sidebar(state)
|
||||
state.sidebar_visible -> %{state | active_view: activity_id}
|
||||
true -> %{state | active_view: activity_id, sidebar_visible: true}
|
||||
end
|
||||
end
|
||||
|
||||
def activity_buttons(state, git_badge_count \\ 0) do
|
||||
Registry.sidebar_views()
|
||||
|> Enum.map(fn view ->
|
||||
%{
|
||||
id: view.id,
|
||||
label: view.label,
|
||||
activity_group: view.activity_group,
|
||||
active: state.sidebar_visible and state.active_view == view.id,
|
||||
badge: activity_badge(view.id, git_badge_count)
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
def status_bar(state, opts) do
|
||||
%{
|
||||
left: %{
|
||||
running_task_message: Keyword.get(opts, :running_task_message),
|
||||
running_task_overflow: Keyword.get(opts, :running_task_overflow)
|
||||
},
|
||||
right: %{
|
||||
post_status: post_status(state, Keyword.get(opts, :active_post_status)),
|
||||
post_count: "#{Keyword.get(opts, :post_count, 0)} posts",
|
||||
media_count: "#{Keyword.get(opts, :media_count, 0)} media",
|
||||
token_usage: token_usage(state, Keyword.get(opts, :token_usage)),
|
||||
theme_badge: Keyword.get(opts, :theme_badge, "default"),
|
||||
offline_mode: Keyword.get(opts, :offline_mode, false),
|
||||
ui_language: Keyword.get(opts, :ui_language, "en"),
|
||||
brand: "bDS"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp upsert_tab(tabs, type, id, intent) do
|
||||
transient? = transient_tab?(type, intent)
|
||||
|
||||
case Enum.find_index(tabs, &(tab_ref(&1) == {type, id})) do
|
||||
nil ->
|
||||
new_tab = %{type: type, id: id, is_transient: transient?}
|
||||
|
||||
tabs =
|
||||
cond do
|
||||
transient? -> replace_transient_tab(tabs, type, new_tab)
|
||||
true -> tabs ++ [new_tab]
|
||||
end
|
||||
|
||||
{tabs, new_tab}
|
||||
|
||||
index ->
|
||||
existing = Enum.at(tabs, index)
|
||||
updated = if intent == :pin, do: %{existing | is_transient: false}, else: existing
|
||||
{List.replace_at(tabs, index, updated), updated}
|
||||
end
|
||||
end
|
||||
|
||||
defp replace_transient_tab(tabs, type, new_tab) do
|
||||
case Enum.find_index(tabs, &(&1.type == type and &1.is_transient)) do
|
||||
nil -> tabs ++ [new_tab]
|
||||
index -> List.replace_at(tabs, index, new_tab)
|
||||
end
|
||||
end
|
||||
|
||||
defp transient_tab?(type, _intent) when type in [:chat, :import], do: false
|
||||
defp transient_tab?(type, intent) do
|
||||
if MapSet.member?(@singleton_tabs, type), do: false, else: transient_from_intent(intent)
|
||||
end
|
||||
|
||||
defp transient_from_intent(:preview), do: true
|
||||
defp transient_from_intent(_intent), do: false
|
||||
|
||||
defp sync_editor_route(%{active_tab: nil} = state), do: %{state | editor_route: :dashboard}
|
||||
defp sync_editor_route(%{active_tab: {type, _id}} = state), do: %{state | editor_route: type}
|
||||
|
||||
defp normalize_panel(state) do
|
||||
if panel_tab_available?(state.editor_route, state.panel.active_tab) do
|
||||
state
|
||||
else
|
||||
%{state | panel: %{state.panel | active_tab: :tasks}}
|
||||
end
|
||||
end
|
||||
|
||||
defp panel_tab_available?(_route, tab) when tab in [:tasks, :output], do: true
|
||||
defp panel_tab_available?(:post, :post_links), do: true
|
||||
defp panel_tab_available?(route, :git_log) when route in [:post, :media], do: true
|
||||
defp panel_tab_available?(_route, _tab), do: false
|
||||
|
||||
defp activity_badge(:git, count) when is_integer(count) and count > 0 do
|
||||
%{count: count, display: if(count > 99, do: "99+", else: Integer.to_string(count))}
|
||||
end
|
||||
|
||||
defp activity_badge(_id, _count), do: nil
|
||||
|
||||
defp post_status(%{editor_route: :post}, status) when not is_nil(status), do: to_string(status)
|
||||
defp post_status(_state, _status), do: nil
|
||||
|
||||
defp token_usage(%{editor_route: :chat}, usage), do: usage
|
||||
defp token_usage(_state, _usage), do: nil
|
||||
|
||||
defp normalize_type(type) when is_atom(type), do: type
|
||||
defp normalize_type(type) when is_binary(type), do: String.to_atom(type)
|
||||
|
||||
defp tab_ref(tab), do: {tab.type, tab.id}
|
||||
|
||||
defp clamp_sidebar_width(width), do: max(200, min(width, 500))
|
||||
defp clamp_assistant_sidebar_width(width), do: max(280, min(width, 640))
|
||||
end
|
||||
Reference in New Issue
Block a user