fix: more work on liveview
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
4
PLAN.md
4
PLAN.md
@@ -13,7 +13,7 @@ The rewrite already implements most of the backend and compatibility-critical su
|
|||||||
- Compatibility parity locks: executable parity coverage now pins the old-bDS behavior for serializer/file contracts, metadata-diff ordering, canonical generation routes, feed output, localized archive rendering, preview draft language selection, taxonomy archive links, and media URL rewriting.
|
- Compatibility parity locks: executable parity coverage now pins the old-bDS behavior for serializer/file contracts, metadata-diff ordering, canonical generation routes, feed output, localized archive rendering, preview draft language selection, taxonomy archive links, and media URL rewriting.
|
||||||
- Core editorial domains: projects, posts, post translations, media, media translations, tags, templates, scripts, menu data, and project metadata.
|
- Core editorial domains: projects, posts, post translations, media, media translations, tags, templates, scripts, menu data, and project metadata.
|
||||||
- Output and infrastructure: search, Liquid rendering, site generation, preview server, publishing, background tasks, i18n, git support, MCP server, AI operations, embeddings, and CLI sync.
|
- Output and infrastructure: search, Liquid rendering, site generation, preview server, publishing, background tasks, i18n, git support, MCP server, AI operations, embeddings, and CLI sync.
|
||||||
- Desktop shell foundation: native menu definitions, LiveView shell endpoint, activity bar, sidebar views, tab/workbench state, task panel, assistant sidebar, project switcher, and shell command routing.
|
- Desktop shell foundation: native menu definitions, LiveView shell endpoint, activity bar, sidebar views, tab/workbench state, task panel, assistant sidebar, status bar, project switcher, shell command routing, and template-backed shell rendering.
|
||||||
|
|
||||||
### Implemented But Not Yet At Parity
|
### Implemented But Not Yet At Parity
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ The remaining work needs to proceed from base contracts upward. Later phases sho
|
|||||||
Save/publish/delete side-effects, published-post `templateSlug` frontmatter rewrites, manual-translation source-post reopening, post-to-media sidecar cleanup, auto-translation task cascades, linked-media translation cascades, link graph maintenance, thumbnail regeneration rules, and rebuild notifications are now implemented and covered at the backend layer independent of UI.
|
Save/publish/delete side-effects, published-post `templateSlug` frontmatter rewrites, manual-translation source-post reopening, post-to-media sidecar cleanup, auto-translation task cascades, linked-media translation cascades, link graph maintenance, thumbnail regeneration rules, and rebuild notifications are now implemented and covered at the backend layer independent of UI.
|
||||||
|
|
||||||
3. Finish the desktop shell primitives. Completed 2026-04-25.
|
3. Finish the desktop shell primitives. Completed 2026-04-25.
|
||||||
Route state, registry-backed shell command coverage, panel fallback integration, and menu/native-command wiring now cover every sidebar view and singleton editor route while preserving the old app shell frame and styling.
|
Route state, registry-backed shell command coverage, panel fallback integration, menu/native-command wiring, template-backed LiveView rendering, and sidebar-to-tab/status-bar interactions now cover every sidebar view and singleton editor route while preserving the old app shell frame and styling.
|
||||||
|
|
||||||
4. Implement the shared modal and confirmation layer.
|
4. Implement the shared modal and confirmation layer.
|
||||||
Add the modal/overlay system required by the specs: AI suggestions, delete confirmations, merge confirmation, picker overlays, and gallery flows.
|
Add the modal/overlay system required by the specs: AI suggestions, delete confirmations, merge confirmation, picker overlays, and gallery flows.
|
||||||
|
|||||||
@@ -87,13 +87,13 @@ defmodule BDS.Desktop.ShellData do
|
|||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
def status_bar(workbench, task_status, dashboard) do
|
def status_bar(workbench, task_status, dashboard, opts \\ []) do
|
||||||
Workbench.status_bar(workbench,
|
Workbench.status_bar(workbench,
|
||||||
post_count: dashboard.post_stats.total_posts,
|
post_count: dashboard.post_stats.total_posts,
|
||||||
media_count: dashboard.media_stats.media_count,
|
media_count: dashboard.media_stats.media_count,
|
||||||
theme_badge: "desktop-shell",
|
theme_badge: "desktop-shell",
|
||||||
ui_language: ui_language(),
|
ui_language: Keyword.get(opts, :ui_language, ui_language()),
|
||||||
offline_mode: true,
|
offline_mode: Keyword.get(opts, :offline_mode, true),
|
||||||
running_task_message: task_status.running_task_message,
|
running_task_message: task_status.running_task_message,
|
||||||
running_task_overflow: task_status.running_task_overflow,
|
running_task_overflow: task_status.running_task_overflow,
|
||||||
active_post_status: nil
|
active_post_status: nil
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
import Phoenix.HTML
|
import Phoenix.HTML
|
||||||
|
|
||||||
alias BDS.Desktop.ShellData
|
alias BDS.Desktop.ShellData
|
||||||
|
alias BDS.UI.Registry
|
||||||
alias BDS.UI.Workbench
|
alias BDS.UI.Workbench
|
||||||
|
|
||||||
@refresh_interval 1_500
|
@refresh_interval 1_500
|
||||||
|
|
||||||
|
embed_templates "shell_live/*"
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
if connected?(socket) do
|
if connected?(socket) do
|
||||||
@@ -22,6 +25,8 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
socket
|
socket
|
||||||
|> assign(:page_title, ShellData.title())
|
|> assign(:page_title, ShellData.title())
|
||||||
|> assign(:page_language, ShellData.ui_language())
|
|> assign(:page_language, ShellData.ui_language())
|
||||||
|
|> assign(:offline_mode, true)
|
||||||
|
|> assign(:tab_meta, %{})
|
||||||
|> reload_shell(workbench)}
|
|> reload_shell(workbench)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -52,6 +57,37 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
{:noreply, reload_shell(socket, workbench)}
|
{:noreply, reload_shell(socket, workbench)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("open_sidebar_item", %{"route" => route, "id" => id} = params, socket) do
|
||||||
|
route_atom = sidebar_route_atom(route)
|
||||||
|
tab_id = tab_id_for_route(route_atom, id)
|
||||||
|
|
||||||
|
workbench =
|
||||||
|
Workbench.open_tab(socket.assigns.workbench, route_atom, tab_id, tab_intent(route_atom))
|
||||||
|
|
||||||
|
tab_meta =
|
||||||
|
Map.put(socket.assigns.tab_meta, {route_atom, tab_id}, %{
|
||||||
|
title: Map.get(params, "title", ""),
|
||||||
|
subtitle: Map.get(params, "subtitle", "")
|
||||||
|
})
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:tab_meta, tab_meta)
|
||||||
|
|> reload_shell(workbench)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("select_tab", %{"type" => type, "id" => id}, socket) do
|
||||||
|
workbench =
|
||||||
|
Workbench.open_tab(socket.assigns.workbench, String.to_existing_atom(type), id, :preview)
|
||||||
|
|
||||||
|
{:noreply, reload_shell(socket, workbench)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("toggle_offline_mode", _params, socket) do
|
||||||
|
socket = assign(socket, :offline_mode, not socket.assigns.offline_mode)
|
||||||
|
{:noreply, reload_shell(socket, socket.assigns.workbench)}
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info(:refresh_task_status, socket) do
|
def handle_info(:refresh_task_status, socket) do
|
||||||
task_status = BDS.Tasks.status_snapshot()
|
task_status = BDS.Tasks.status_snapshot()
|
||||||
@@ -60,312 +96,17 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
socket
|
socket
|
||||||
|> assign(:task_status, task_status)
|
|> assign(:task_status, task_status)
|
||||||
|> assign(:editor_meta, ShellData.editor_meta(task_status))
|
|> assign(:editor_meta, ShellData.editor_meta(task_status))
|
||||||
|> assign(:status, ShellData.status_bar(socket.assigns.workbench, task_status, socket.assigns.dashboard))}
|
|> assign(
|
||||||
|
:status,
|
||||||
|
ShellData.status_bar(socket.assigns.workbench, task_status, socket.assigns.dashboard,
|
||||||
|
ui_language: socket.assigns.page_language,
|
||||||
|
offline_mode: socket.assigns.offline_mode
|
||||||
|
)
|
||||||
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns), do: index(assigns)
|
||||||
~H"""
|
|
||||||
<div class="app" id="bds-shell-app">
|
|
||||||
<div class="window-titlebar" data-region="title-bar">
|
|
||||||
<div class="window-titlebar-menu-bar is-hidden">
|
|
||||||
<button class="window-titlebar-menu-button" type="button">File</button>
|
|
||||||
<button class="window-titlebar-menu-button" type="button">Edit</button>
|
|
||||||
<button class="window-titlebar-menu-button" type="button">View</button>
|
|
||||||
<button class="window-titlebar-menu-button" type="button">Blog</button>
|
|
||||||
<button class="window-titlebar-menu-button" type="button">Help</button>
|
|
||||||
</div>
|
|
||||||
<div class="window-titlebar-drag-region"></div>
|
|
||||||
<div class="window-titlebar-title" data-testid="window-title"><%= @page_title %></div>
|
|
||||||
<div class="window-titlebar-actions">
|
|
||||||
<button
|
|
||||||
class="window-titlebar-action-button"
|
|
||||||
data-testid="toggle-sidebar"
|
|
||||||
type="button"
|
|
||||||
phx-click="toggle_sidebar"
|
|
||||||
aria-label="Toggle sidebar"
|
|
||||||
title="Toggle sidebar"
|
|
||||||
>
|
|
||||||
<span class={["window-titlebar-sidebar-icon", if(@workbench.sidebar_visible, do: "is-active", else: "is-inactive")]}>
|
|
||||||
<span class="window-titlebar-sidebar-pane"></span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="window-titlebar-action-button"
|
|
||||||
data-testid="toggle-panel"
|
|
||||||
type="button"
|
|
||||||
phx-click="toggle_panel"
|
|
||||||
aria-label="Toggle panel"
|
|
||||||
title="Toggle panel"
|
|
||||||
>
|
|
||||||
<span class={["window-titlebar-panel-icon", if(@workbench.panel.visible, do: "is-active", else: "is-inactive")]}>
|
|
||||||
<span class="window-titlebar-panel-pane"></span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="window-titlebar-action-button"
|
|
||||||
data-testid="toggle-assistant"
|
|
||||||
type="button"
|
|
||||||
phx-click="toggle_assistant_sidebar"
|
|
||||||
aria-label="Toggle assistant"
|
|
||||||
title="Toggle assistant"
|
|
||||||
>
|
|
||||||
<span class={["window-titlebar-assistant-icon", if(@workbench.assistant_sidebar_visible, do: "is-active", else: "is-inactive")]}>
|
|
||||||
<span class="window-titlebar-assistant-pane"></span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="app-main">
|
|
||||||
<aside class="activity-bar" data-region="activity-bar">
|
|
||||||
<div class="activity-bar-top">
|
|
||||||
<%= for button <- Enum.filter(@activity_buttons, &(&1.activity_group == :top)) do %>
|
|
||||||
<button
|
|
||||||
class={["activity-bar-item", if(button.active, do: "active")]}
|
|
||||||
data-testid="activity-button"
|
|
||||||
data-view={button.id}
|
|
||||||
data-active={to_string(button.active)}
|
|
||||||
type="button"
|
|
||||||
phx-click="select_view"
|
|
||||||
phx-value-view={button.id}
|
|
||||||
title={activity_label(button.label)}
|
|
||||||
aria-label={activity_label(button.label)}
|
|
||||||
>
|
|
||||||
<%= raw(ShellData.activity_icon(button.id)) %>
|
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<div class="activity-bar-bottom">
|
|
||||||
<%= for button <- Enum.filter(@activity_buttons, &(&1.activity_group == :bottom)) do %>
|
|
||||||
<button
|
|
||||||
class={["activity-bar-item", if(button.active, do: "active")]}
|
|
||||||
data-testid="activity-button"
|
|
||||||
data-view={button.id}
|
|
||||||
data-active={to_string(button.active)}
|
|
||||||
type="button"
|
|
||||||
phx-click="select_view"
|
|
||||||
phx-value-view={button.id}
|
|
||||||
title={activity_label(button.label)}
|
|
||||||
aria-label={activity_label(button.label)}
|
|
||||||
>
|
|
||||||
<%= raw(ShellData.activity_icon(button.id)) %>
|
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section
|
|
||||||
class={["sidebar-shell", if(not @workbench.sidebar_visible, do: "is-hidden")]}
|
|
||||||
data-testid="sidebar-shell"
|
|
||||||
style={"width: #{@workbench.sidebar_width}px;"}
|
|
||||||
>
|
|
||||||
<div class="sidebar" data-region="sidebar">
|
|
||||||
<div class="sidebar-content sidebar-body">
|
|
||||||
<div class="sidebar-section">
|
|
||||||
<div class="sidebar-section-header">
|
|
||||||
<span><%= String.upcase(sidebar_header_label(@sidebar_header)) %></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<%= render_sidebar_body(assigns) %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="resizable-panel-divider sidebar-divider" data-resize="sidebar" data-role="resize-handle"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<main class="app-content" data-region="content">
|
|
||||||
<div class="tab-bar" data-region="tab-bar"></div>
|
|
||||||
<section class="editor-shell" data-region="editor">
|
|
||||||
<div class="editor-empty">
|
|
||||||
<div class="dashboard-content">
|
|
||||||
<h1 data-testid="editor-title"><%= translated("dashboard.title") %></h1>
|
|
||||||
<p class="text-muted"><%= translated("dashboard.subtitle") %></p>
|
|
||||||
|
|
||||||
<div class="dashboard-stats">
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-number"><%= @dashboard.post_stats.total_posts || 0 %></div>
|
|
||||||
<div class="stat-label"><%= translated("dashboard.stats.totalPosts") %></div>
|
|
||||||
<div class="stat-breakdown">
|
|
||||||
<span class="stat-tag stat-published"><%= translated("dashboard.stats.published", %{count: @dashboard.post_stats.published_count || 0}) %></span>
|
|
||||||
<span class="stat-tag stat-draft"><%= translated("dashboard.stats.drafts", %{count: @dashboard.post_stats.draft_count || 0}) %></span>
|
|
||||||
<%= if (@dashboard.post_stats.archived_count || 0) > 0 do %>
|
|
||||||
<span class="stat-tag stat-archived"><%= translated("dashboard.stats.archived", %{count: @dashboard.post_stats.archived_count || 0}) %></span>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-number"><%= @dashboard.media_stats.media_count || 0 %></div>
|
|
||||||
<div class="stat-label"><%= translated("dashboard.stats.mediaFiles") %></div>
|
|
||||||
<div class="stat-breakdown">
|
|
||||||
<span class="stat-tag"><%= translated("dashboard.stats.images", %{count: @dashboard.media_stats.image_count || 0}) %></span>
|
|
||||||
<span class="stat-tag"><%= ShellData.format_bytes(@dashboard.media_stats.total_bytes || 0) %></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-number"><%= length(@dashboard.tag_cloud_items || []) %></div>
|
|
||||||
<div class="stat-label"><%= translated("dashboard.stats.tags") %></div>
|
|
||||||
<div class="stat-breakdown">
|
|
||||||
<span class="stat-tag"><%= translated("dashboard.stats.categories", %{count: length(@dashboard.category_counts || [])}) %></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= if Enum.any?(@dashboard.timeline_entries || []) do %>
|
|
||||||
<div class="dashboard-section">
|
|
||||||
<h4><%= translated("dashboard.section.postsOverTime") %></h4>
|
|
||||||
<div class="timeline-chart">
|
|
||||||
<%= for entry <- @dashboard.timeline_entries || [] do %>
|
|
||||||
<div class="timeline-bar-container">
|
|
||||||
<div class="timeline-bar" style={"height: #{timeline_height(entry, @dashboard.timeline_entries || [])}%"}>
|
|
||||||
<span class="timeline-bar-count"><%= entry.count || 0 %></span>
|
|
||||||
</div>
|
|
||||||
<div class="timeline-bar-label">
|
|
||||||
<span class="timeline-bar-label-month"><%= ShellData.format_dashboard_month(entry.year, entry.month) %></span>
|
|
||||||
<span class="timeline-bar-label-year"><%= entry.year %></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= if Enum.any?(@dashboard_tag_cloud_items) do %>
|
|
||||||
<div class="dashboard-section">
|
|
||||||
<h4><%= translated("dashboard.section.tags") %></h4>
|
|
||||||
<div class="tag-cloud">
|
|
||||||
<%= for item <- @dashboard_tag_cloud_items do %>
|
|
||||||
<span class={["dashboard-tag", if(item.color, do: "has-color")]} style={ShellData.render_dashboard_tag_style(item)} title={ShellData.dashboard_post_count_label(item.count)}><%= item.tag %></span>
|
|
||||||
<% end %>
|
|
||||||
<%= if length(@dashboard.tag_cloud_items || []) > 40 do %>
|
|
||||||
<span class="text-muted tag-cloud-more"><%= translated("dashboard.tagCloud.more", %{count: length(@dashboard.tag_cloud_items) - 40}) %></span>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= if Enum.any?(@dashboard.category_counts || []) do %>
|
|
||||||
<div class="dashboard-section">
|
|
||||||
<h4><%= translated("dashboard.section.categories") %></h4>
|
|
||||||
<div class="tag-cloud">
|
|
||||||
<%= for category <- @dashboard.category_counts || [] do %>
|
|
||||||
<span class="dashboard-tag dashboard-category" title={ShellData.dashboard_post_count_label(category.count || 0)}>
|
|
||||||
<%= category.category || "" %>
|
|
||||||
<span class="tag-count"><%= category.count || 0 %></span>
|
|
||||||
</span>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= if Enum.any?(@dashboard.recent_posts || []) do %>
|
|
||||||
<div class="dashboard-section">
|
|
||||||
<h4><%= translated("dashboard.section.recentlyUpdated") %></h4>
|
|
||||||
<div class="recent-posts-list">
|
|
||||||
<%= for post <- @dashboard.recent_posts || [] do %>
|
|
||||||
<button class="recent-post-item" type="button">
|
|
||||||
<span class="recent-post-title"><%= post.title || "" %></span>
|
|
||||||
<span class={"recent-post-status status-#{post.status || "draft"}"}><%= ShellData.dashboard_status_label(post.status || "draft") %></span>
|
|
||||||
<span class="recent-post-date"><%= ShellData.format_dashboard_date(post.updated_at) %></span>
|
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div class="dashboard-inspector-meta" hidden>
|
|
||||||
<%= for item <- @editor_meta do %>
|
|
||||||
<section class="editor-meta-row">
|
|
||||||
<strong data-testid="editor-meta-label"><%= translated(item.label) %></strong>
|
|
||||||
<span><%= translated(item.value) %></span>
|
|
||||||
</section>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class={["panel-shell", if(not @workbench.panel.visible, do: "is-hidden")]} data-region="panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<div class="panel-tabs">
|
|
||||||
<%= for tab <- @panel_tabs do %>
|
|
||||||
<button
|
|
||||||
class={["panel-tab", if(@workbench.panel.active_tab == tab, do: "active")]}
|
|
||||||
type="button"
|
|
||||||
phx-click="select_panel_tab"
|
|
||||||
phx-value-tab={tab}
|
|
||||||
>
|
|
||||||
<%= panel_tab_label(tab) %>
|
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="panel-content">
|
|
||||||
<%= render_panel_body(assigns) %>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<section
|
|
||||||
class={["assistant-sidebar-shell", if(not @workbench.assistant_sidebar_visible, do: "is-hidden")]}
|
|
||||||
data-testid="assistant-shell"
|
|
||||||
style={"width: #{@workbench.assistant_sidebar_width}px;"}
|
|
||||||
>
|
|
||||||
<div class="resizable-panel-divider assistant-divider" data-resize="assistant" data-role="resize-handle"></div>
|
|
||||||
<aside class="assistant-sidebar" data-region="assistant-sidebar">
|
|
||||||
<div class="assistant-header">
|
|
||||||
<strong><%= translated("Assistant") %></strong>
|
|
||||||
</div>
|
|
||||||
<div class="assistant-content">
|
|
||||||
<%= for card <- @assistant_cards do %>
|
|
||||||
<section class="assistant-card">
|
|
||||||
<strong><%= translated(card.label) %></strong>
|
|
||||||
<span><%= translated(card.text) %></span>
|
|
||||||
</section>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="status-bar" data-region="status-bar">
|
|
||||||
<div class="status-bar-left">
|
|
||||||
<div class="project-selector">
|
|
||||||
<button class="project-selector-trigger" type="button" title={translated("Switch project")}>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" class="project-icon">
|
|
||||||
<path d="M14.5 3H7.71l-.85-.85A.5.5 0 0 0 6.5 2h-5a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5v-10a.5.5 0 0 0-.5-.5zm-13 1h5.29l.85.85c.1.1.23.15.36.15h6.5v9h-13V4z"></path>
|
|
||||||
</svg>
|
|
||||||
<span class="project-name"><%= @current_project && @current_project.name || "My Blog" %></span>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" class="dropdown-arrow">
|
|
||||||
<path d="M4.5 5.5L8 9l3.5-3.5h-7z"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button class="status-bar-item status-bar-task-button" type="button" phx-click="toggle_panel">
|
|
||||||
<span><%= @status.left.running_task_message || translated("Idle") %></span>
|
|
||||||
<%= if (@status.left.running_task_overflow || 0) > 0 do %>
|
|
||||||
<span class="status-bar-count">+<%= @status.left.running_task_overflow %></span>
|
|
||||||
<% end %>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="status-bar-right">
|
|
||||||
<span class="status-bar-item"><%= @status.right.post_count %></span>
|
|
||||||
<span class="status-bar-item"><%= @status.right.media_count %></span>
|
|
||||||
<span class="status-bar-item theme-badge"><%= @status.right.theme_badge %></span>
|
|
||||||
<button class={["status-bar-item", "offline-badge", if(@status.right.offline_mode, do: "active")]} type="button" title={translated("Toggle offline mode")}>✈</button>
|
|
||||||
<label class="status-bar-item language-badge">
|
|
||||||
<span><%= translated("UI") %></span>
|
|
||||||
<select class="status-bar-language-select">
|
|
||||||
<%= for language <- @supported_ui_languages do %>
|
|
||||||
<option selected={language.code == @page_language} value={language.code}><%= language.flag %></option>
|
|
||||||
<% end %>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<span class="status-bar-item brand"><%= @status.right.brand %></span>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
|
|
||||||
defp reload_shell(socket, workbench) do
|
defp reload_shell(socket, workbench) do
|
||||||
projects = ShellData.project_snapshot()
|
projects = ShellData.project_snapshot()
|
||||||
@@ -373,22 +114,34 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
sidebar_data = ShellData.sidebar_view(projects.active_project_id, Atom.to_string(workbench.active_view))
|
sidebar_data = ShellData.sidebar_view(projects.active_project_id, Atom.to_string(workbench.active_view))
|
||||||
task_status = BDS.Tasks.status_snapshot()
|
task_status = BDS.Tasks.status_snapshot()
|
||||||
activity_buttons = Workbench.activity_buttons(workbench, 0)
|
activity_buttons = Workbench.activity_buttons(workbench, 0)
|
||||||
|
page_language = socket.assigns[:page_language] || ShellData.ui_language()
|
||||||
|
offline_mode = Map.get(socket.assigns, :offline_mode, true)
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(:workbench, workbench)
|
|> assign(:workbench, workbench)
|
||||||
|> assign(:projects, projects)
|
|> assign(:projects, projects)
|
||||||
|> assign(:current_project, ShellData.current_project(projects))
|
|> assign(:current_project, ShellData.current_project(projects))
|
||||||
|> assign(:dashboard, dashboard)
|
|> assign(:dashboard, dashboard)
|
||||||
|> assign(:dashboard_tag_cloud_items, ShellData.dashboard_tag_cloud_items(dashboard.tag_cloud_items || []))
|
|> assign(:dashboard_timeline_entries, Map.get(dashboard, :timeline_entries, []))
|
||||||
|
|> assign(:dashboard_category_counts, Map.get(dashboard, :category_counts, []))
|
||||||
|
|> assign(:dashboard_recent_posts, Map.get(dashboard, :recent_posts, []))
|
||||||
|
|> assign(:dashboard_tag_cloud_items, ShellData.dashboard_tag_cloud_items(Map.get(dashboard, :tag_cloud_items, [])))
|
||||||
|> assign(:sidebar_data, sidebar_data)
|
|> assign(:sidebar_data, sidebar_data)
|
||||||
|> assign(:sidebar_header, active_sidebar_label(activity_buttons, workbench.active_view, sidebar_data))
|
|> assign(:sidebar_header, active_sidebar_label(activity_buttons, workbench.active_view, sidebar_data))
|
||||||
|> assign(:assistant_cards, ShellData.assistant_cards())
|
|> assign(:assistant_cards, ShellData.assistant_cards())
|
||||||
|> assign(:editor_meta, ShellData.editor_meta(task_status))
|
|> assign(:editor_meta, ShellData.editor_meta(task_status))
|
||||||
|> assign(:task_status, task_status)
|
|> assign(:task_status, task_status)
|
||||||
|> assign(:status, ShellData.status_bar(workbench, task_status, dashboard))
|
|> assign(
|
||||||
|
:status,
|
||||||
|
ShellData.status_bar(workbench, task_status, dashboard,
|
||||||
|
ui_language: page_language,
|
||||||
|
offline_mode: offline_mode
|
||||||
|
)
|
||||||
|
)
|
||||||
|> assign(:activity_buttons, activity_buttons)
|
|> assign(:activity_buttons, activity_buttons)
|
||||||
|> assign(:panel_tabs, ShellData.panel_tabs(workbench))
|
|> assign(:panel_tabs, ShellData.panel_tabs(workbench))
|
||||||
|> assign(:supported_ui_languages, ShellData.supported_ui_languages())
|
|> assign(:supported_ui_languages, ShellData.supported_ui_languages())
|
||||||
|
|> assign(:current_tab, current_tab(workbench))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp render_sidebar_body(assigns) do
|
defp render_sidebar_body(assigns) do
|
||||||
@@ -403,20 +156,31 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|
|
||||||
defp render_post_sidebar(assigns) do
|
defp render_post_sidebar(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<%= for section <- @sidebar_data.sections || [] do %>
|
<%= for section <- Map.get(@sidebar_data, :sections, []) do %>
|
||||||
<section class="sidebar-section">
|
<section class="sidebar-section">
|
||||||
<div class="sidebar-section-title">
|
<div class="sidebar-section-title">
|
||||||
<span class={"section-icon status-#{section.status || "draft"}"}>●</span>
|
<span class={"section-icon status-#{Map.get(section, :status, "draft")}"}>●</span>
|
||||||
<span data-testid="sidebar-section-title"><%= translated(section.title) %></span>
|
<span data-testid="sidebar-section-title"><%= translated(section.title) %></span>
|
||||||
<span class="sidebar-section-count"><%= section.count || length(section.items || []) %></span>
|
<span class="sidebar-section-count"><%= Map.get(section, :count, length(Map.get(section, :items, []))) %></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-list">
|
<div class="sidebar-list">
|
||||||
<%= for item <- section.items || [] do %>
|
<%= for item <- Map.get(section, :items, []) do %>
|
||||||
<button class="sidebar-item sidebar-post-item post-type-post" type="button">
|
<button
|
||||||
|
class={["sidebar-item", "sidebar-post-item", "post-type-post", if(sidebar_item_selected?(@workbench, item.route, item.id), do: "selected")]}
|
||||||
|
data-testid="sidebar-open-item"
|
||||||
|
data-route={item.route}
|
||||||
|
data-item-id={item.id}
|
||||||
|
type="button"
|
||||||
|
phx-click="open_sidebar_item"
|
||||||
|
phx-value-route={item.route}
|
||||||
|
phx-value-id={item.id}
|
||||||
|
phx-value-title={item.title}
|
||||||
|
phx-value-subtitle={format_sidebar_timestamp(item.meta_timestamp)}
|
||||||
|
>
|
||||||
<span class="post-type-icon" title="post">●</span>
|
<span class="post-type-icon" title="post">●</span>
|
||||||
<span class="sidebar-item-content">
|
<span class="sidebar-item-content">
|
||||||
<span class="sidebar-item-title-row">
|
<span class="sidebar-item-title-row">
|
||||||
<span class="sidebar-item-title"><%= item.title || "" %></span>
|
<span class="sidebar-item-title"><%= item.title %></span>
|
||||||
</span>
|
</span>
|
||||||
<span class="sidebar-item-meta"><%= format_sidebar_timestamp(item.meta_timestamp) %></span>
|
<span class="sidebar-item-meta"><%= format_sidebar_timestamp(item.meta_timestamp) %></span>
|
||||||
</span>
|
</span>
|
||||||
@@ -425,9 +189,9 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= if Enum.empty?(@sidebar_data.sections || []) do %>
|
<%= if Enum.empty?(Map.get(@sidebar_data, :sections, [])) do %>
|
||||||
<div class="sidebar-empty">
|
<div class="sidebar-empty">
|
||||||
<p><%= translated(@sidebar_data.empty_message || "No items") %></p>
|
<p><%= translated(Map.get(@sidebar_data, :empty_message, "No items")) %></p>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
"""
|
"""
|
||||||
@@ -435,10 +199,22 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|
|
||||||
defp render_media_sidebar(assigns) do
|
defp render_media_sidebar(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<%= if Enum.any?(@sidebar_data.items || []) do %>
|
<%= if Enum.any?(Map.get(@sidebar_data, :items, [])) do %>
|
||||||
<div class="sidebar-list media-grid">
|
<div class="sidebar-list media-grid">
|
||||||
<%= for item <- @sidebar_data.items || [] do %>
|
<%= for item <- Map.get(@sidebar_data, :items, []) do %>
|
||||||
<button class="media-item" type="button" title={item.title || ""}>
|
<button
|
||||||
|
class={["media-item", if(sidebar_item_selected?(@workbench, item.route, item.id), do: "selected")]}
|
||||||
|
data-testid="sidebar-open-item"
|
||||||
|
data-route={item.route}
|
||||||
|
data-item-id={item.id}
|
||||||
|
type="button"
|
||||||
|
title={item.title}
|
||||||
|
phx-click="open_sidebar_item"
|
||||||
|
phx-value-route={item.route}
|
||||||
|
phx-value-id={item.id}
|
||||||
|
phx-value-title={item.title}
|
||||||
|
phx-value-subtitle={item.meta}
|
||||||
|
>
|
||||||
<span class={media_thumbnail_class(item)}>
|
<span class={media_thumbnail_class(item)}>
|
||||||
<%= if image_media?(item) do %>
|
<%= if image_media?(item) do %>
|
||||||
<span class="media-thumbnail-fallback"><%= media_thumbnail_glyph(item.mime_type) %></span>
|
<span class="media-thumbnail-fallback"><%= media_thumbnail_glyph(item.mime_type) %></span>
|
||||||
@@ -448,15 +224,15 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
<% end %>
|
<% end %>
|
||||||
</span>
|
</span>
|
||||||
<span class="media-item-info">
|
<span class="media-item-info">
|
||||||
<span class="media-item-name"><%= item.title || "" %></span>
|
<span class="media-item-name"><%= item.title %></span>
|
||||||
<span class="media-item-size"><%= item.meta || "" %></span>
|
<span class="media-item-size"><%= item.meta %></span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="sidebar-empty">
|
<div class="sidebar-empty">
|
||||||
<p><%= translated(@sidebar_data.empty_message || "No items") %></p>
|
<p><%= translated(Map.get(@sidebar_data, :empty_message, "No items")) %></p>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
"""
|
"""
|
||||||
@@ -464,12 +240,23 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|
|
||||||
defp render_entity_sidebar(assigns) do
|
defp render_entity_sidebar(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<%= if Enum.any?(@sidebar_data.items || []) do %>
|
<%= if Enum.any?(Map.get(@sidebar_data, :items, [])) do %>
|
||||||
<div class="settings-nav-list">
|
<div class="settings-nav-list">
|
||||||
<%= for item <- @sidebar_data.items || [] do %>
|
<%= for item <- Map.get(@sidebar_data, :items, []) do %>
|
||||||
<button class="chat-list-item" type="button">
|
<button
|
||||||
|
class={["chat-list-item", if(sidebar_item_selected?(@workbench, item.route, item.id), do: "active")]}
|
||||||
|
data-testid="sidebar-open-item"
|
||||||
|
data-route={item.route}
|
||||||
|
data-item-id={item.id}
|
||||||
|
type="button"
|
||||||
|
phx-click="open_sidebar_item"
|
||||||
|
phx-value-route={item.route}
|
||||||
|
phx-value-id={item.id}
|
||||||
|
phx-value-title={item.title}
|
||||||
|
phx-value-subtitle={translated(item.meta || "")}
|
||||||
|
>
|
||||||
<span class="chat-item-content">
|
<span class="chat-item-content">
|
||||||
<span class="chat-item-title"><%= item.title || "" %></span>
|
<span class="chat-item-title"><%= item.title %></span>
|
||||||
<span class="chat-item-date"><%= translated(item.meta || "") %></span>
|
<span class="chat-item-date"><%= translated(item.meta || "") %></span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -477,7 +264,7 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="sidebar-empty">
|
<div class="sidebar-empty">
|
||||||
<p><%= translated(@sidebar_data.empty_message || "No items") %></p>
|
<p><%= translated(Map.get(@sidebar_data, :empty_message, "No items")) %></p>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
"""
|
"""
|
||||||
@@ -486,10 +273,21 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
defp render_nav_sidebar(assigns) do
|
defp render_nav_sidebar(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<div class="settings-nav-list">
|
<div class="settings-nav-list">
|
||||||
<%= for item <- @sidebar_data.items || [] do %>
|
<%= for item <- Map.get(@sidebar_data, :items, []) do %>
|
||||||
<button class="settings-nav-entry" type="button">
|
<button
|
||||||
<span class="settings-nav-entry-icon"><%= item.icon || "" %></span>
|
class="settings-nav-entry"
|
||||||
<span><%= translated(item.title || "") %></span>
|
data-testid="sidebar-open-item"
|
||||||
|
data-route={item.route}
|
||||||
|
data-item-id={item.id}
|
||||||
|
type="button"
|
||||||
|
phx-click="open_sidebar_item"
|
||||||
|
phx-value-route={item.route}
|
||||||
|
phx-value-id={item.id}
|
||||||
|
phx-value-title={translated(item.title)}
|
||||||
|
phx-value-subtitle={translated(Map.get(@sidebar_data, :subtitle, ""))}
|
||||||
|
>
|
||||||
|
<span class="settings-nav-entry-icon"><%= Map.get(item, :icon, "") %></span>
|
||||||
|
<span><%= translated(item.title) %></span>
|
||||||
</button>
|
</button>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
@@ -498,13 +296,13 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|
|
||||||
defp render_default_sidebar(assigns) do
|
defp render_default_sidebar(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<%= for section <- @sidebar_data.sections || [] do %>
|
<%= for section <- Map.get(@sidebar_data, :sections, []) do %>
|
||||||
<section class="sidebar-section">
|
<section class="sidebar-section">
|
||||||
<div class="sidebar-section-header">
|
<div class="sidebar-section-header">
|
||||||
<span data-testid="sidebar-section-title"><%= translated(section.title) %></span>
|
<span data-testid="sidebar-section-title"><%= translated(section.title) %></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-section-items">
|
<div class="sidebar-section-items">
|
||||||
<%= for item <- section.items || [] do %>
|
<%= for item <- Map.get(section, :items, []) do %>
|
||||||
<div class="sidebar-list-item"><%= item.title || "" %></div>
|
<div class="sidebar-list-item"><%= item.title || "" %></div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
@@ -524,14 +322,14 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|
|
||||||
defp render_task_entries(assigns) do
|
defp render_task_entries(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<%= if Enum.empty?(@task_status.tasks || []) do %>
|
<%= if Enum.empty?(Map.get(@task_status, :tasks, [])) do %>
|
||||||
<div class="panel-entry panel-empty-state">
|
<div class="panel-entry panel-empty-state">
|
||||||
<strong><%= translated("Tasks") %></strong>
|
<strong><%= translated("Tasks") %></strong>
|
||||||
<span><%= translated("No background tasks running") %></span>
|
<span><%= translated("No background tasks running") %></span>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="task-list">
|
<div class="task-list">
|
||||||
<%= for task <- @task_status.tasks || [] do %>
|
<%= for task <- Map.get(@task_status, :tasks, []) do %>
|
||||||
<div class="panel-entry task-entry">
|
<div class="panel-entry task-entry">
|
||||||
<div class="task-entry-header">
|
<div class="task-entry-header">
|
||||||
<strong><%= task.name %></strong>
|
<strong><%= task.name %></strong>
|
||||||
@@ -588,7 +386,7 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
defp activity_label(label), do: translated(label)
|
defp activity_label(label), do: translated(label)
|
||||||
|
|
||||||
defp active_sidebar_label(activity_buttons, active_view, sidebar_data) do
|
defp active_sidebar_label(activity_buttons, active_view, sidebar_data) do
|
||||||
Enum.find_value(activity_buttons, translated(sidebar_data.title || ""), fn button ->
|
Enum.find_value(activity_buttons, translated(Map.get(sidebar_data, :title, "")), fn button ->
|
||||||
if button.id == active_view, do: activity_label(button.label), else: nil
|
if button.id == active_view, do: activity_label(button.label), else: nil
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
@@ -618,6 +416,68 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
if image_media?(item), do: "media-thumbnail has-image", else: "media-thumbnail"
|
if image_media?(item), do: "media-thumbnail has-image", else: "media-thumbnail"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp current_tab(%{active_tab: nil}), do: nil
|
||||||
|
|
||||||
|
defp current_tab(%{tabs: tabs, active_tab: {type, id}}) do
|
||||||
|
Enum.find(tabs, &(&1.type == type and &1.id == id))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sidebar_route_atom(route) when is_atom(route), do: route
|
||||||
|
defp sidebar_route_atom(route) when is_binary(route), do: String.to_existing_atom(route)
|
||||||
|
|
||||||
|
defp tab_id_for_route(route, id) do
|
||||||
|
case Registry.editor_route(route) do
|
||||||
|
%{singleton: true} -> Atom.to_string(route)
|
||||||
|
_other -> id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp tab_intent(route) do
|
||||||
|
case Registry.editor_route(route) do
|
||||||
|
%{singleton: true} -> :pin
|
||||||
|
_other -> :preview
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sidebar_item_selected?(workbench, route, id) do
|
||||||
|
route_atom = sidebar_route_atom(route)
|
||||||
|
workbench.active_tab == {route_atom, tab_id_for_route(route_atom, id)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp tab_title(nil, _tab_meta), do: translated("Dashboard")
|
||||||
|
|
||||||
|
defp tab_title(tab, tab_meta) do
|
||||||
|
case Map.get(tab_meta, {tab.type, tab.id}) do
|
||||||
|
%{title: title} when is_binary(title) and title != "" -> title
|
||||||
|
_other -> default_tab_title(tab)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp tab_subtitle(nil, _tab_meta), do: translated("dashboard.subtitle")
|
||||||
|
|
||||||
|
defp tab_subtitle(tab, tab_meta) do
|
||||||
|
case Map.get(tab_meta, {tab.type, tab.id}) do
|
||||||
|
%{subtitle: subtitle} when is_binary(subtitle) and subtitle != "" -> subtitle
|
||||||
|
_other -> "Desktop workbench content routed through the Elixir shell."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp default_tab_title(%{type: type, id: id}) do
|
||||||
|
case Registry.editor_route(type) do
|
||||||
|
%{singleton: true} -> ShellData.route_label(type)
|
||||||
|
_other -> id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp tab_route_label(nil), do: translated("Dashboard")
|
||||||
|
defp tab_route_label(%{type: type}), do: ShellData.route_label(type)
|
||||||
|
|
||||||
|
defp tab_icon_id(nil), do: "posts"
|
||||||
|
defp tab_icon_id(%{type: :post}), do: "posts"
|
||||||
|
defp tab_icon_id(%{type: :git_diff}), do: "git"
|
||||||
|
defp tab_icon_id(%{type: :style}), do: "settings"
|
||||||
|
defp tab_icon_id(%{type: type}), do: Atom.to_string(type)
|
||||||
|
|
||||||
defp media_thumbnail_glyph(mime_type) do
|
defp media_thumbnail_glyph(mime_type) do
|
||||||
case String.split(to_string(mime_type || ""), "/", parts: 2) do
|
case String.split(to_string(mime_type || ""), "/", parts: 2) do
|
||||||
["image", _rest] -> "IMG"
|
["image", _rest] -> "IMG"
|
||||||
|
|||||||
348
lib/bds/desktop/shell_live/index.html.heex
Normal file
348
lib/bds/desktop/shell_live/index.html.heex
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
<div class="app" id="bds-shell-app">
|
||||||
|
<div class="window-titlebar" data-region="title-bar">
|
||||||
|
<div class="window-titlebar-menu-bar is-hidden">
|
||||||
|
<button class="window-titlebar-menu-button" type="button">File</button>
|
||||||
|
<button class="window-titlebar-menu-button" type="button">Edit</button>
|
||||||
|
<button class="window-titlebar-menu-button" type="button">View</button>
|
||||||
|
<button class="window-titlebar-menu-button" type="button">Blog</button>
|
||||||
|
<button class="window-titlebar-menu-button" type="button">Help</button>
|
||||||
|
</div>
|
||||||
|
<div class="window-titlebar-drag-region"></div>
|
||||||
|
<div class="window-titlebar-title" data-testid="window-title"><%= @page_title %></div>
|
||||||
|
<div class="window-titlebar-actions">
|
||||||
|
<button
|
||||||
|
class="window-titlebar-action-button"
|
||||||
|
data-testid="toggle-sidebar"
|
||||||
|
type="button"
|
||||||
|
phx-click="toggle_sidebar"
|
||||||
|
aria-label="Toggle sidebar"
|
||||||
|
title="Toggle sidebar"
|
||||||
|
>
|
||||||
|
<span class={["window-titlebar-sidebar-icon", if(@workbench.sidebar_visible, do: "is-active", else: "is-inactive")]}>
|
||||||
|
<span class="window-titlebar-sidebar-pane"></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="window-titlebar-action-button"
|
||||||
|
data-testid="toggle-panel"
|
||||||
|
type="button"
|
||||||
|
phx-click="toggle_panel"
|
||||||
|
aria-label="Toggle panel"
|
||||||
|
title="Toggle panel"
|
||||||
|
>
|
||||||
|
<span class={["window-titlebar-panel-icon", if(@workbench.panel.visible, do: "is-active", else: "is-inactive")]}>
|
||||||
|
<span class="window-titlebar-panel-pane"></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="window-titlebar-action-button"
|
||||||
|
data-testid="toggle-assistant"
|
||||||
|
type="button"
|
||||||
|
phx-click="toggle_assistant_sidebar"
|
||||||
|
aria-label="Toggle assistant"
|
||||||
|
title="Toggle assistant"
|
||||||
|
>
|
||||||
|
<span class={["window-titlebar-assistant-icon", if(@workbench.assistant_sidebar_visible, do: "is-active", else: "is-inactive")]}>
|
||||||
|
<span class="window-titlebar-assistant-pane"></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="app-main">
|
||||||
|
<aside class="activity-bar" data-region="activity-bar">
|
||||||
|
<div class="activity-bar-top">
|
||||||
|
<%= for button <- Enum.filter(@activity_buttons, &(&1.activity_group == :top)) do %>
|
||||||
|
<button
|
||||||
|
class={["activity-bar-item", if(button.active, do: "active")]}
|
||||||
|
data-testid="activity-button"
|
||||||
|
data-view={button.id}
|
||||||
|
data-active={to_string(button.active)}
|
||||||
|
type="button"
|
||||||
|
phx-click="select_view"
|
||||||
|
phx-value-view={button.id}
|
||||||
|
title={activity_label(button.label)}
|
||||||
|
aria-label={activity_label(button.label)}
|
||||||
|
>
|
||||||
|
<%= raw(ShellData.activity_icon(button.id)) %>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="activity-bar-bottom">
|
||||||
|
<%= for button <- Enum.filter(@activity_buttons, &(&1.activity_group == :bottom)) do %>
|
||||||
|
<button
|
||||||
|
class={["activity-bar-item", if(button.active, do: "active")]}
|
||||||
|
data-testid="activity-button"
|
||||||
|
data-view={button.id}
|
||||||
|
data-active={to_string(button.active)}
|
||||||
|
type="button"
|
||||||
|
phx-click="select_view"
|
||||||
|
phx-value-view={button.id}
|
||||||
|
title={activity_label(button.label)}
|
||||||
|
aria-label={activity_label(button.label)}
|
||||||
|
>
|
||||||
|
<%= raw(ShellData.activity_icon(button.id)) %>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class={["sidebar-shell", if(not @workbench.sidebar_visible, do: "is-hidden")]}
|
||||||
|
data-testid="sidebar-shell"
|
||||||
|
style={"width: #{@workbench.sidebar_width}px;"}
|
||||||
|
>
|
||||||
|
<div class="sidebar" data-region="sidebar">
|
||||||
|
<div class="sidebar-content sidebar-body">
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="sidebar-section-header">
|
||||||
|
<span><%= String.upcase(sidebar_header_label(@sidebar_header)) %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<%= render_sidebar_body(assigns) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="resizable-panel-divider sidebar-divider" data-resize="sidebar" data-role="resize-handle"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<main class="app-content" data-region="content">
|
||||||
|
<div class="tab-bar" data-region="tab-bar">
|
||||||
|
<%= if Enum.empty?(@workbench.tabs) do %>
|
||||||
|
<div class="tab-bar-empty"><%= translated("Dashboard") %></div>
|
||||||
|
<% else %>
|
||||||
|
<div class="tab-bar-tabs">
|
||||||
|
<%= for tab <- @workbench.tabs do %>
|
||||||
|
<button
|
||||||
|
class={["tab", if(@workbench.active_tab == {tab.type, tab.id}, do: "active"), if(tab.is_transient, do: "transient")]}
|
||||||
|
data-tab-type={tab.type}
|
||||||
|
data-tab-id={tab.id}
|
||||||
|
type="button"
|
||||||
|
phx-click="select_tab"
|
||||||
|
phx-value-type={tab.type}
|
||||||
|
phx-value-id={tab.id}
|
||||||
|
>
|
||||||
|
<span class="tab-icon"><%= raw(ShellData.activity_icon(tab_icon_id(tab))) %></span>
|
||||||
|
<span class="tab-title"><%= tab_title(tab, @tab_meta) %></span>
|
||||||
|
<span class="tab-close" aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="editor-shell" data-region="editor">
|
||||||
|
<%= if is_nil(@current_tab) do %>
|
||||||
|
<div class="editor-empty">
|
||||||
|
<div class="dashboard-content">
|
||||||
|
<h1 data-testid="editor-title"><%= translated("dashboard.title") %></h1>
|
||||||
|
<p class="text-muted"><%= translated("dashboard.subtitle") %></p>
|
||||||
|
|
||||||
|
<div class="dashboard-stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number"><%= @dashboard.post_stats.total_posts || 0 %></div>
|
||||||
|
<div class="stat-label"><%= translated("dashboard.stats.totalPosts") %></div>
|
||||||
|
<div class="stat-breakdown">
|
||||||
|
<span class="stat-tag stat-published"><%= translated("dashboard.stats.published", %{count: @dashboard.post_stats.published_count || 0}) %></span>
|
||||||
|
<span class="stat-tag stat-draft"><%= translated("dashboard.stats.drafts", %{count: @dashboard.post_stats.draft_count || 0}) %></span>
|
||||||
|
<%= if (@dashboard.post_stats.archived_count || 0) > 0 do %>
|
||||||
|
<span class="stat-tag stat-archived"><%= translated("dashboard.stats.archived", %{count: @dashboard.post_stats.archived_count || 0}) %></span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number"><%= @dashboard.media_stats.media_count || 0 %></div>
|
||||||
|
<div class="stat-label"><%= translated("dashboard.stats.mediaFiles") %></div>
|
||||||
|
<div class="stat-breakdown">
|
||||||
|
<span class="stat-tag"><%= translated("dashboard.stats.images", %{count: @dashboard.media_stats.image_count || 0}) %></span>
|
||||||
|
<span class="stat-tag"><%= ShellData.format_bytes(@dashboard.media_stats.total_bytes || 0) %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number"><%= length(@dashboard_tag_cloud_items) %></div>
|
||||||
|
<div class="stat-label"><%= translated("dashboard.stats.tags") %></div>
|
||||||
|
<div class="stat-breakdown">
|
||||||
|
<span class="stat-tag"><%= translated("dashboard.stats.categories", %{count: length(@dashboard_category_counts)}) %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= if Enum.any?(@dashboard_timeline_entries) do %>
|
||||||
|
<div class="dashboard-section">
|
||||||
|
<h4><%= translated("dashboard.section.postsOverTime") %></h4>
|
||||||
|
<div class="timeline-chart">
|
||||||
|
<%= for entry <- @dashboard_timeline_entries do %>
|
||||||
|
<div class="timeline-bar-container">
|
||||||
|
<div class="timeline-bar" style={"height: #{timeline_height(entry, @dashboard_timeline_entries)}%"}>
|
||||||
|
<span class="timeline-bar-count"><%= entry.count || 0 %></span>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-bar-label">
|
||||||
|
<span class="timeline-bar-label-month"><%= ShellData.format_dashboard_month(entry.year, entry.month) %></span>
|
||||||
|
<span class="timeline-bar-label-year"><%= entry.year %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= if Enum.any?(@dashboard_tag_cloud_items) do %>
|
||||||
|
<div class="dashboard-section">
|
||||||
|
<h4><%= translated("dashboard.section.tags") %></h4>
|
||||||
|
<div class="tag-cloud">
|
||||||
|
<%= for item <- @dashboard_tag_cloud_items do %>
|
||||||
|
<span class={["dashboard-tag", if(item.color, do: "has-color")]} style={ShellData.render_dashboard_tag_style(item)} title={ShellData.dashboard_post_count_label(item.count)}><%= item.tag %></span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= if Enum.any?(@dashboard_category_counts) do %>
|
||||||
|
<div class="dashboard-section">
|
||||||
|
<h4><%= translated("dashboard.section.categories") %></h4>
|
||||||
|
<div class="tag-cloud">
|
||||||
|
<%= for category <- @dashboard_category_counts do %>
|
||||||
|
<span class="dashboard-tag dashboard-category" title={ShellData.dashboard_post_count_label(category.count || 0)}>
|
||||||
|
<%= category.category || "" %>
|
||||||
|
<span class="tag-count"><%= category.count || 0 %></span>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= if Enum.any?(@dashboard_recent_posts) do %>
|
||||||
|
<div class="dashboard-section">
|
||||||
|
<h4><%= translated("dashboard.section.recentlyUpdated") %></h4>
|
||||||
|
<div class="recent-posts-list">
|
||||||
|
<%= for post <- @dashboard_recent_posts do %>
|
||||||
|
<button class="recent-post-item" type="button">
|
||||||
|
<span class="recent-post-title"><%= post.title || "" %></span>
|
||||||
|
<span class={"recent-post-status status-#{post.status || "draft"}"}><%= ShellData.dashboard_status_label(post.status || "draft") %></span>
|
||||||
|
<span class="recent-post-date"><%= ShellData.format_dashboard_date(post.updated_at) %></span>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="dashboard-inspector-meta" hidden>
|
||||||
|
<%= for item <- @editor_meta do %>
|
||||||
|
<section class="editor-meta-row">
|
||||||
|
<strong data-testid="editor-meta-label"><%= translated(item.label) %></strong>
|
||||||
|
<span><%= translated(item.value) %></span>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="editor-frame">
|
||||||
|
<section class="editor-main">
|
||||||
|
<div class="editor-kicker"><%= tab_route_label(@current_tab) %></div>
|
||||||
|
<h1 class="editor-title" data-testid="editor-title"><%= tab_title(@current_tab, @tab_meta) %></h1>
|
||||||
|
<p class="editor-subtitle"><%= tab_subtitle(@current_tab, @tab_meta) %></p>
|
||||||
|
|
||||||
|
<div class="editor-toolbar">
|
||||||
|
<button class="editor-toolbar-button" type="button">Open</button>
|
||||||
|
<button class="editor-toolbar-button" type="button">Preview</button>
|
||||||
|
<button class="editor-toolbar-button" type="button">Metadata</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-section">
|
||||||
|
<h2><%= tab_title(@current_tab, @tab_meta) %></h2>
|
||||||
|
<p>Desktop workbench content routed through the Elixir shell.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="editor-meta">
|
||||||
|
<%= for item <- @editor_meta do %>
|
||||||
|
<section class="editor-meta-row">
|
||||||
|
<strong data-testid="editor-meta-label"><%= translated(item.label) %></strong>
|
||||||
|
<span><%= translated(item.value) %></span>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class={["panel-shell", if(not @workbench.panel.visible, do: "is-hidden")]} data-region="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="panel-tabs">
|
||||||
|
<%= for tab <- @panel_tabs do %>
|
||||||
|
<button
|
||||||
|
class={["panel-tab", if(@workbench.panel.active_tab == tab, do: "active")]}
|
||||||
|
type="button"
|
||||||
|
phx-click="select_panel_tab"
|
||||||
|
phx-value-tab={tab}
|
||||||
|
>
|
||||||
|
<%= panel_tab_label(tab) %>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-content">
|
||||||
|
<%= render_panel_body(assigns) %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class={["assistant-sidebar-shell", if(not @workbench.assistant_sidebar_visible, do: "is-hidden")]}
|
||||||
|
data-testid="assistant-shell"
|
||||||
|
style={"width: #{@workbench.assistant_sidebar_width}px;"}
|
||||||
|
>
|
||||||
|
<div class="resizable-panel-divider assistant-divider" data-resize="assistant" data-role="resize-handle"></div>
|
||||||
|
<aside class="assistant-sidebar" data-region="assistant-sidebar">
|
||||||
|
<div class="assistant-header">
|
||||||
|
<strong><%= translated("Assistant") %></strong>
|
||||||
|
</div>
|
||||||
|
<div class="assistant-content">
|
||||||
|
<%= for card <- @assistant_cards do %>
|
||||||
|
<section class="assistant-card">
|
||||||
|
<strong><%= translated(card.label) %></strong>
|
||||||
|
<span><%= translated(card.text) %></span>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="status-bar" data-region="status-bar" data-testid="status-bar">
|
||||||
|
<div class="status-bar-left">
|
||||||
|
<div class="project-selector">
|
||||||
|
<button class="project-selector-trigger" type="button" title={translated("Switch project")}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" class="project-icon">
|
||||||
|
<path d="M14.5 3H7.71l-.85-.85A.5.5 0 0 0 6.5 2h-5a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5v-10a.5.5 0 0 0-.5-.5zm-13 1h5.29l.85.85c.1.1.23.15.36.15h6.5v9h-13V4z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="project-name"><%= if @current_project, do: @current_project.name, else: "My Blog" %></span>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" class="dropdown-arrow">
|
||||||
|
<path d="M4.5 5.5L8 9l3.5-3.5h-7z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="status-bar-item status-bar-task-button" data-testid="status-task-button" type="button" phx-click="toggle_panel">
|
||||||
|
<span><%= @status.left.running_task_message || translated("Idle") %></span>
|
||||||
|
<%= if (@status.left.running_task_overflow || 0) > 0 do %>
|
||||||
|
<span class="status-bar-count">+<%= @status.left.running_task_overflow %></span>
|
||||||
|
<% end %>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="status-bar-right">
|
||||||
|
<span class="status-bar-item"><%= @status.right.post_count %></span>
|
||||||
|
<span class="status-bar-item"><%= @status.right.media_count %></span>
|
||||||
|
<span class="status-bar-item theme-badge"><%= @status.right.theme_badge %></span>
|
||||||
|
<button class={["status-bar-item", "offline-badge", if(@status.right.offline_mode, do: "active")]} data-testid="status-offline-button" type="button" phx-click="toggle_offline_mode" title={translated("Toggle offline mode")}>✈</button>
|
||||||
|
<label class="status-bar-item language-badge">
|
||||||
|
<span><%= translated("UI") %></span>
|
||||||
|
<select class="status-bar-language-select">
|
||||||
|
<%= for language <- @supported_ui_languages do %>
|
||||||
|
<option selected={language.code == @page_language} value={language.code}><%= language.flag %></option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span class="status-bar-item brand"><%= @status.right.brand %></span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
@@ -192,7 +192,7 @@ defmodule BDS.Sidecar do
|
|||||||
else
|
else
|
||||||
inner
|
inner
|
||||||
|> String.split(",", trim: true)
|
|> String.split(",", trim: true)
|
||||||
|> Enum.map(&(String.trim(&1) |> parse_scalar(nil)))
|
|> Enum.map(&parse_scalar(nil, String.trim(&1)))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,13 @@ body {
|
|||||||
font-size: var(--vscode-font-size);
|
font-size: var(--vscode-font-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body > [data-phx-session],
|
||||||
|
body > [data-phx-main] {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
|
||||||
assert html =~ ~s(data-testid="sidebar-shell")
|
assert html =~ ~s(data-testid="sidebar-shell")
|
||||||
|
assert html =~ ~s(data-testid="status-bar")
|
||||||
|
assert html =~ ~s(data-testid="status-task-button")
|
||||||
assert html =~ ~s(class="panel-shell is-hidden")
|
assert html =~ ~s(class="panel-shell is-hidden")
|
||||||
assert html =~ ~s(data-testid="activity-button")
|
assert html =~ ~s(data-testid="activity-button")
|
||||||
assert html =~ ~s(data-view="posts")
|
assert html =~ ~s(data-view="posts")
|
||||||
@@ -38,5 +40,20 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
|
|
||||||
assert html =~ ~s(aria-label="Media")
|
assert html =~ ~s(aria-label="Media")
|
||||||
assert html =~ ~s(data-view="media")
|
assert html =~ ~s(data-view="media")
|
||||||
|
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> element("[data-testid='activity-button'][data-view='settings']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
assert html =~ ~s(data-testid="sidebar-open-item")
|
||||||
|
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> element("[data-testid='sidebar-open-item'][data-item-id='settings-project']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
assert html =~ ~s(data-tab-type="settings")
|
||||||
|
assert html =~ ">Settings<"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user