feat: first take at UI app

This commit is contained in:
2026-04-24 14:54:04 +02:00
parent 78609377be
commit 1b5a5008eb
24 changed files with 2630 additions and 3 deletions

16
lib/bds/ui/commands.ex Normal file
View 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
View 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
View 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
View 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
View 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
View 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