feat: first take on sidebars

This commit is contained in:
2026-04-25 20:26:55 +02:00
parent 7ebea742a5
commit 55b3071696
11 changed files with 951 additions and 168 deletions

View File

@@ -6,6 +6,7 @@ defmodule BDS.UI.ShellPage do
alias BDS.UI.Dashboard
alias BDS.UI.MenuBar
alias BDS.UI.Registry
alias BDS.UI.Sidebar
alias BDS.UI.Session
alias BDS.UI.Workbench
@@ -84,7 +85,7 @@ defmodule BDS.UI.ShellPage do
session: Session.serialize(workbench),
task_status: task_status,
content: %{
sidebar: sidebar_content(),
sidebar: sidebar_content(projects.active_project_id),
dashboard: dashboard,
assistant_cards: assistant_cards(),
editor_meta: editor_meta(task_status)
@@ -162,72 +163,15 @@ defmodule BDS.UI.ShellPage do
}
end
defp sidebar_content do
%{
"posts" => %{
title: "Posts",
subtitle: "Drafts, published entries, and archive history",
sections: [
%{
title: "Drafts",
items: [
%{id: "post-welcome", title: "Welcome to bDS2", meta: "Updated today", badge: "draft", route: "post"},
%{id: "post-launch-plan", title: "Launch plan", meta: "Updated yesterday", badge: "draft", route: "post"}
]
},
%{
title: "Published",
items: [
%{id: "post-roadmap", title: "Roadmap", meta: "Published Feb 10, 2026", badge: "2 langs", route: "post"}
]
},
%{
title: "Archived",
items: [
%{id: "post-retrospective", title: "Retrospective", meta: "Archived Jan 12, 2026", badge: "archive", route: "post"}
]
}
]
},
"pages" => simple_list_view("Pages", "Standalone pages", [
%{id: "page-about", title: "About", meta: "Static page", route: "post"},
%{id: "page-contact", title: "Contact", meta: "Static page", route: "post"}
]),
"media" => simple_list_view("Media", "Images and files", [
%{id: "media-hero", title: "hero-shot.jpg", meta: "Image asset", route: "media"},
%{id: "media-banner", title: "launch-banner.png", meta: "Image asset", route: "media"}
]),
"scripts" => simple_list_view("Scripts", "Automation helpers", [
%{id: "script-import", title: "Import posts", meta: "Lua utility", route: "scripts"},
%{id: "script-sync", title: "Sync tags", meta: "Lua utility", route: "scripts"}
]),
"templates" => simple_list_view("Templates", "Site rendering", [
%{id: "template-post", title: "post.liquid", meta: "Post template", route: "templates"},
%{id: "template-list", title: "list.liquid", meta: "List template", route: "templates"}
]),
"tags" => simple_list_view("Tags", "Tag management", [
%{id: "tag-launch", title: "launch", meta: "12 posts", route: "tags"},
%{id: "tag-writing", title: "writing", meta: "7 posts", route: "tags"}
]),
"chat" => simple_list_view("Chat", "AI conversations", [
%{id: "chat-planning", title: "Planning session", meta: "Offline gated", route: "chat"},
%{id: "chat-translation", title: "Translation QA", meta: "Offline gated", route: "chat"}
]),
"import" => simple_list_view("Import", "Import definitions", [
%{id: "import-wordpress", title: "WordPress import", meta: "Ready", route: "import"}
]),
"git" => simple_list_view("Git", "Working tree and history", [
%{id: "git-working-tree", title: "Working tree", meta: "3 changed files", route: "git_diff"}
]),
"settings" => simple_list_view("Settings", "Project and publishing", [
%{id: "settings-project", title: "Project", meta: "Paths and defaults", route: "settings"},
%{id: "settings-ai", title: "AI", meta: "Offline controls", route: "settings"}
])
}
end
defp sidebar_content(project_id) do
Sidebar.snapshot(project_id)
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__
end
defp simple_list_view(title, subtitle, items) do
%{title: title, subtitle: subtitle, sections: [%{title: title, items: items}]}
Sidebar.empty_snapshot()
end
defp dashboard_content(project_id) do

295
lib/bds/ui/sidebar.ex Normal file
View File

@@ -0,0 +1,295 @@
defmodule BDS.UI.Sidebar do
@moduledoc false
import Ecto.Query
alias BDS.AI.ChatConversation
alias BDS.Media.Media
alias BDS.Posts.Post
alias BDS.Posts.Translation
alias BDS.Repo
alias BDS.Scripts.Script
alias BDS.Tags.Tag
alias BDS.Templates.Template
@page_category "page"
def snapshot(nil), do: empty_snapshot()
def snapshot(project_id) when is_binary(project_id) do
posts = list_posts(project_id)
translation_counts = translation_counts(project_id)
media_items = list_media(project_id)
scripts = list_scripts(project_id)
templates = list_templates(project_id)
tags = list_tags(project_id)
conversations = list_conversations()
%{
"posts" => posts_view(posts, translation_counts, false),
"pages" => posts_view(posts, translation_counts, true),
"media" => media_view(media_items),
"scripts" => entity_list_view("Scripts", "Automation helpers", "scripts", scripts),
"templates" =>
entity_list_view("Templates", "Site rendering", "templates", templates),
"tags" => tags_nav_view(tags),
"chat" => entity_list_view("Chat", "AI conversations", "chat", conversations),
"import" => entity_list_view("Import", "Import definitions", "import", []),
"git" => git_view(),
"settings" => settings_nav_view()
}
end
def empty_snapshot do
%{
"posts" => posts_view([], %{}, false),
"pages" => posts_view([], %{}, true),
"media" => media_view([]),
"scripts" => entity_list_view("Scripts", "Automation helpers", "scripts", []),
"templates" => entity_list_view("Templates", "Site rendering", "templates", []),
"tags" => tags_nav_view([]),
"chat" => entity_list_view("Chat", "AI conversations", "chat", []),
"import" => entity_list_view("Import", "Import definitions", "import", []),
"git" => git_view(),
"settings" => settings_nav_view()
}
end
defp posts_view(posts, translation_counts, pages?) do
filtered_posts = Enum.filter(posts, &(page_post?(&1) == pages?))
grouped_posts = group_posts(filtered_posts)
%{
title: if(pages?, do: "Pages", else: "Posts"),
subtitle: if(pages?, do: "Standalone pages", else: "Drafts, published entries, and archive history"),
layout: "post_list",
empty_message: if(pages?, do: "No items", else: "No items"),
sections: [
build_post_section("Drafts", :draft, grouped_posts.draft, translation_counts, false),
build_post_section("Published", :published, grouped_posts.published, translation_counts, true),
build_post_section("Archived", :archived, grouped_posts.archived, translation_counts, false)
]
}
end
defp media_view(media_items) do
%{
title: "Media",
subtitle: "Images and files",
layout: "media_grid",
empty_message: "No items",
items:
Enum.map(media_items, fn media ->
%{
id: media.id,
title: display_media_title(media),
meta: media_size_label(media.size),
mime_type: media.mime_type,
route: "media"
}
end)
}
end
defp tags_nav_view(tags) do
%{
title: "Tags",
subtitle: "Tag management",
layout: "nav_list",
items: [
%{id: "tags-cloud", title: "Tag Cloud", icon: "☁️", route: "tags"},
%{id: "tags-manage", title: "Create / Edit", icon: "✏️", route: "tags"},
%{id: "tags-merge", title: "Merge Tags", icon: "🔀", route: "tags"}
],
summary_badge: length(tags)
}
end
defp settings_nav_view do
%{
title: "Settings",
subtitle: "Project and publishing",
layout: "nav_list",
items: [
%{id: "settings-project", title: "Project", icon: "📁", route: "settings"},
%{id: "settings-editor", title: "Editor", icon: "📝", route: "settings"},
%{id: "settings-content", title: "Content", icon: "📋", route: "settings"},
%{id: "settings-ai", title: "AI", icon: "🤖", route: "settings"},
%{id: "settings-technology", title: "Technology", icon: "⚙️", route: "settings"},
%{id: "settings-publishing", title: "Publishing", icon: "🚀", route: "settings"},
%{id: "settings-data", title: "Data", icon: "🗄️", route: "settings"},
%{id: "settings-mcp", title: "MCP", icon: "🔌", route: "settings"},
%{id: "settings-style", title: "Style", icon: "🎨", route: "style"}
]
}
end
defp git_view do
%{
title: "Git",
subtitle: "Working tree and history",
layout: "entity_list",
empty_message: "No items",
items: [
%{id: "git-working-tree", title: "Working tree", meta: "Working tree and history", route: "git_diff", updated_at: nil}
]
}
end
defp entity_list_view(title, subtitle, route, items) do
%{
title: title,
subtitle: subtitle,
layout: "entity_list",
empty_message: "No items",
items:
Enum.map(items, fn item ->
%{
id: item.id,
title: item.title,
meta: Map.get(item, :meta),
updated_at: Map.get(item, :updated_at),
route: route
}
end)
}
end
defp build_post_section(title, status, posts, translation_counts, published_meta?) do
%{
id: Atom.to_string(status),
title: title,
status: Atom.to_string(status),
count: length(posts),
items:
Enum.map(posts, fn post ->
%{
id: post.id,
title: display_post_title(post),
categories: post.categories || [],
language_count: 1 + Map.get(translation_counts, post.id, 0),
meta_timestamp: if(published_meta?, do: post.published_at || post.updated_at, else: post.updated_at),
route: "post"
}
end)
}
end
defp list_posts(project_id) do
Repo.all(
from post in Post,
where: post.project_id == ^project_id,
order_by: [desc: post.updated_at, desc: post.created_at],
select: %{
id: post.id,
title: post.title,
slug: post.slug,
status: post.status,
categories: post.categories,
updated_at: post.updated_at,
published_at: post.published_at
}
)
end
defp translation_counts(project_id) do
Repo.all(
from translation in Translation,
where: translation.project_id == ^project_id,
group_by: translation.translation_for,
select: {translation.translation_for, count(translation.id)}
)
|> Map.new()
end
defp list_media(project_id) do
Repo.all(
from media in Media,
where: media.project_id == ^project_id,
order_by: [desc: media.updated_at, desc: media.created_at],
select: %{
id: media.id,
title: media.title,
original_name: media.original_name,
mime_type: media.mime_type,
size: media.size
}
)
end
defp list_scripts(project_id) do
Repo.all(
from script in Script,
where: script.project_id == ^project_id,
order_by: [desc: script.updated_at, desc: script.created_at],
select: %{id: script.id, title: script.title, updated_at: script.updated_at}
)
end
defp list_templates(project_id) do
Repo.all(
from template in Template,
where: template.project_id == ^project_id,
order_by: [desc: template.updated_at, desc: template.created_at],
select: %{id: template.id, title: template.title, updated_at: template.updated_at}
)
end
defp list_tags(project_id) do
Repo.all(
from tag in Tag,
where: tag.project_id == ^project_id,
order_by: [asc: tag.name],
select: %{id: tag.id, title: tag.name, updated_at: tag.updated_at}
)
end
defp list_conversations do
Repo.all(
from conversation in ChatConversation,
order_by: [desc: conversation.updated_at, desc: conversation.created_at],
select: %{id: conversation.id, title: conversation.title, updated_at: conversation.updated_at}
)
end
defp group_posts(posts) do
Enum.reduce(posts, %{draft: [], published: [], archived: []}, fn post, acc ->
case post.status do
:draft -> %{acc | draft: acc.draft ++ [post]}
:published -> %{acc | published: acc.published ++ [post]}
:archived -> %{acc | archived: acc.archived ++ [post]}
_other -> acc
end
end)
end
defp page_post?(post) do
Enum.any?(post.categories || [], &(String.downcase(to_string(&1)) == @page_category))
end
defp display_post_title(post) do
cond do
present?(post.title) -> post.title
present?(post.slug) -> post.slug
true -> "Untitled"
end
end
defp display_media_title(media) do
if present?(media.title), do: media.title, else: media.original_name || ""
end
defp media_size_label(size) when is_integer(size) and size < 1024, do: "#{size} B"
defp media_size_label(size) when is_integer(size) and size < 1024 * 1024 do
:erlang.float_to_binary(size / 1024, decimals: 1) <> " KB"
end
defp media_size_label(size) when is_integer(size) do
:erlang.float_to_binary(size / (1024 * 1024), decimals: 1) <> " MB"
end
defp media_size_label(_size), do: "0 B"
defp present?(value), do: value not in [nil, ""]
end

View File

@@ -65,7 +65,12 @@
"Command failed": "Befehl fehlgeschlagen",
"Command failed with HTTP %{status}": "Befehl mit HTTP %{status} fehlgeschlagen",
"Create Project": "Projekt erstellen",
"Create / Edit": "Erstellen / Bearbeiten",
"Content": "Inhalte",
"Data": "Daten",
"Dashboard": "Instrumententafel",
"AI conversations": "KI-Gespräche",
"Automation helpers": "Automatisierungshilfen",
"dashboard.postCount.one": "%{count} Beitrag",
"dashboard.postCount.other": "%{count} Beiträge",
"dashboard.section.categories": "Kategorien",
@@ -87,6 +92,20 @@
"dashboard.tagCloud.more": "+%{count} weitere",
"dashboard.title": "Übersicht",
"Desktop Runtime": "Desktop-Laufzeit",
"Editor": "Editor",
"Images and files": "Bilder und Dateien",
"Import definitions": "Importdefinitionen",
"Merge Tags": "Tags zusammenführen",
"Project": "Projekt",
"Project and publishing": "Projekt und Veröffentlichung",
"Publishing": "Veröffentlichung",
"Site rendering": "Website-Rendering",
"Standalone pages": "Eigenständige Seiten",
"Tag Cloud": "Tag-Wolke",
"Tag management": "Tag-Verwaltung",
"Technology": "Technik",
"Working tree": "Arbeitsverzeichnis",
"Working tree and history": "Arbeitsverzeichnis und Verlauf",
"Desktop workbench content routed through the Elixir shell.": "Desktop-Arbeitsbereichsinhalte werden durch die Elixir-Shell geleitet.",
"Desktop workbench shell wired through Elixir": "Desktop-Workbench-Shell über Elixir verdrahtet",
"Diff Reports": "Diff-Berichte",

View File

@@ -65,7 +65,12 @@
"Command failed": "Command failed",
"Command failed with HTTP %{status}": "Command failed with HTTP %{status}",
"Create Project": "Create Project",
"Create / Edit": "Create / Edit",
"Content": "Content",
"Data": "Data",
"Dashboard": "Dashboard",
"AI conversations": "AI conversations",
"Automation helpers": "Automation helpers",
"dashboard.postCount.one": "%{count} post",
"dashboard.postCount.other": "%{count} posts",
"dashboard.section.categories": "Categories",
@@ -87,6 +92,20 @@
"dashboard.tagCloud.more": "+%{count} more",
"dashboard.title": "Dashboard",
"Desktop Runtime": "Desktop Runtime",
"Editor": "Editor",
"Images and files": "Images and files",
"Import definitions": "Import definitions",
"Merge Tags": "Merge Tags",
"Project": "Project",
"Project and publishing": "Project and publishing",
"Publishing": "Publishing",
"Site rendering": "Site rendering",
"Standalone pages": "Standalone pages",
"Tag Cloud": "Tag Cloud",
"Tag management": "Tag management",
"Technology": "Technology",
"Working tree": "Working tree",
"Working tree and history": "Working tree and history",
"Desktop workbench content routed through the Elixir shell.": "Desktop workbench content routed through the Elixir shell.",
"Desktop workbench shell wired through Elixir": "Desktop workbench shell wired through Elixir",
"Diff Reports": "Diff Reports",

View File

@@ -65,7 +65,12 @@
"Command failed": "El comando falló",
"Command failed with HTTP %{status}": "El comando falló con HTTP %{status}",
"Create Project": "Crear proyecto",
"Create / Edit": "Crear / editar",
"Content": "Contenido",
"Data": "Datos",
"Dashboard": "Panel",
"AI conversations": "Conversaciones de IA",
"Automation helpers": "Ayudas de automatización",
"dashboard.postCount.one": "%{count} entrada",
"dashboard.postCount.other": "%{count} entradas",
"dashboard.section.categories": "Categorías",
@@ -87,6 +92,20 @@
"dashboard.tagCloud.more": "+%{count} más",
"dashboard.title": "Panel",
"Desktop Runtime": "Entorno de escritorio",
"Editor": "Editor",
"Images and files": "Imágenes y archivos",
"Import definitions": "Definiciones de importación",
"Merge Tags": "Combinar etiquetas",
"Project": "Proyecto",
"Project and publishing": "Proyecto y publicación",
"Publishing": "Publicación",
"Site rendering": "Renderizado del sitio",
"Standalone pages": "Páginas independientes",
"Tag Cloud": "Nube de etiquetas",
"Tag management": "Gestión de etiquetas",
"Technology": "Tecnología",
"Working tree": "Árbol de trabajo",
"Working tree and history": "Árbol de trabajo e historial",
"Desktop workbench content routed through the Elixir shell.": "El contenido del área de trabajo de escritorio se enruta a través del shell de Elixir.",
"Desktop workbench shell wired through Elixir": "Shell del área de trabajo de escritorio conectado mediante Elixir",
"Diff Reports": "Informes de diff",

View File

@@ -65,7 +65,12 @@
"Command failed": "La commande a échoué",
"Command failed with HTTP %{status}": "La commande a échoué avec HTTP %{status}",
"Create Project": "Créer un projet",
"Create / Edit": "Créer / modifier",
"Content": "Contenu",
"Data": "Données",
"Dashboard": "Tableau de bord",
"AI conversations": "Conversations IA",
"Automation helpers": "Aides dautomatisation",
"dashboard.postCount.one": "%{count} article",
"dashboard.postCount.other": "%{count} articles",
"dashboard.section.categories": "Catégories",
@@ -87,6 +92,20 @@
"dashboard.tagCloud.more": "+%{count} de plus",
"dashboard.title": "Tableau de bord",
"Desktop Runtime": "Exécution bureau",
"Editor": "Éditeur",
"Images and files": "Images et fichiers",
"Import definitions": "Définitions dimport",
"Merge Tags": "Fusionner les tags",
"Project": "Projet",
"Project and publishing": "Projet et publication",
"Publishing": "Publication",
"Site rendering": "Rendu du site",
"Standalone pages": "Pages autonomes",
"Tag Cloud": "Nuage de tags",
"Tag management": "Gestion des tags",
"Technology": "Technologie",
"Working tree": "Arbre de travail",
"Working tree and history": "Arbre de travail et historique",
"Desktop workbench content routed through the Elixir shell.": "Le contenu de latelier bureau est acheminé via le shell Elixir.",
"Desktop workbench shell wired through Elixir": "Shell datelier bureau câblé via Elixir",
"Diff Reports": "Rapports de diff",

View File

@@ -65,7 +65,12 @@
"Command failed": "Comando non riuscito",
"Command failed with HTTP %{status}": "Comando non riuscito con HTTP %{status}",
"Create Project": "Crea progetto",
"Create / Edit": "Crea / modifica",
"Content": "Contenuti",
"Data": "Dati",
"Dashboard": "Dashboard",
"AI conversations": "Conversazioni IA",
"Automation helpers": "Strumenti di automazione",
"dashboard.postCount.one": "%{count} post",
"dashboard.postCount.other": "%{count} post",
"dashboard.section.categories": "Categorie",
@@ -87,6 +92,20 @@
"dashboard.tagCloud.more": "+%{count} in più",
"dashboard.title": "Dashboard",
"Desktop Runtime": "Runtime desktop",
"Editor": "Editor",
"Images and files": "Immagini e file",
"Import definitions": "Definizioni di importazione",
"Merge Tags": "Unisci tag",
"Project": "Progetto",
"Project and publishing": "Progetto e pubblicazione",
"Publishing": "Pubblicazione",
"Site rendering": "Rendering del sito",
"Standalone pages": "Pagine autonome",
"Tag Cloud": "Nuvola di tag",
"Tag management": "Gestione tag",
"Technology": "Tecnologia",
"Working tree": "Working tree",
"Working tree and history": "Working tree e cronologia",
"Desktop workbench content routed through the Elixir shell.": "I contenuti del banco di lavoro desktop vengono instradati tramite la shell Elixir.",
"Desktop workbench shell wired through Elixir": "Shell del banco di lavoro desktop collegata tramite Elixir",
"Diff Reports": "Report diff",

View File

@@ -1441,6 +1441,218 @@ button {
white-space: nowrap;
}
.sidebar-section-title {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px 8px;
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.section-icon {
font-size: 11px;
line-height: 1;
}
.section-icon.status-draft {
color: var(--vscode-editorWarning-foreground);
}
.section-icon.status-published {
color: var(--vscode-testing-iconPassed);
}
.section-icon.status-archived {
color: var(--vscode-descriptionForeground);
}
.sidebar-list {
display: flex;
flex-direction: column;
}
.sidebar-post-item {
flex-direction: row;
align-items: flex-start;
gap: 10px;
}
.post-type-icon {
width: 18px;
flex: 0 0 18px;
text-align: center;
line-height: 1.2;
}
.sidebar-item-content {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.sidebar-item-title-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.sidebar-item-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-item-language-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
padding: 0 5px;
border-radius: 999px;
background: rgba(79, 179, 255, 0.14);
color: var(--vscode-titleBar-activeForeground);
font-size: 11px;
}
.sidebar-item-meta {
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.media-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
padding: 0 12px 12px;
}
.media-item {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
padding: 12px;
border: none;
border-radius: 10px;
background: transparent;
color: inherit;
text-align: left;
}
.media-item:hover {
background: var(--vscode-list-hoverBackground);
}
.media-item.selected {
background: var(--vscode-list-activeSelectionBackground);
color: var(--vscode-list-activeSelectionForeground);
}
.media-thumbnail {
display: flex;
align-items: center;
justify-content: center;
height: 72px;
border-radius: 8px;
background: var(--vscode-input-background);
font-size: 28px;
}
.media-item-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.media-item-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-item-size {
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.chat-list-item {
display: flex;
width: 100%;
padding: 10px 12px;
border: none;
background: transparent;
color: inherit;
text-align: left;
}
.chat-list-item:hover {
background: var(--vscode-list-hoverBackground);
}
.chat-list-item.active {
background: var(--vscode-list-activeSelectionBackground);
color: var(--vscode-list-activeSelectionForeground);
}
.chat-item-content {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.chat-item-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-item-date {
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.settings-nav-list {
display: flex;
flex-direction: column;
gap: 6px;
padding: 0 12px 12px;
}
.settings-nav-entry {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
border: none;
border-radius: 10px;
background: transparent;
color: inherit;
text-align: left;
}
.settings-nav-entry:hover {
background: var(--vscode-list-hoverBackground);
}
.settings-nav-entry-icon {
width: 18px;
flex: 0 0 18px;
text-align: center;
}
.sidebar-empty {
padding: 16px 12px;
color: var(--vscode-descriptionForeground);
}
@media (max-width: 820px) {
.dashboard-stats {
grid-template-columns: 1fr;
@@ -1450,4 +1662,8 @@ button {
align-items: flex-start;
flex-wrap: wrap;
}
.media-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -144,7 +144,23 @@ function renderSidebar() {
</div>
</div>
<div class="sidebar-content">
${data.sections
${renderSidebarBody(data, view)}
</div>
`;
}
function renderSidebarBody(data, view) {
switch (data.layout) {
case "post_list":
return renderSidebarPostList(data, view);
case "media_grid":
return renderSidebarMediaGrid(data, view);
case "entity_list":
return renderSidebarEntityList(data, view);
case "nav_list":
return renderSidebarNavList(data, view);
default:
return (data.sections || [])
.map(
(section) => `
<section class="sidebar-section">
@@ -152,17 +168,85 @@ function renderSidebar() {
<span data-testid="sidebar-section-title">${escapeHtml(tText(section.title))}</span>
</div>
<div class="sidebar-section-items">
${section.items.map((item) => renderSidebarItem(item, view)).join("")}
${(section.items || []).map((item) => renderSidebarItem(item, view)).join("")}
</div>
</section>
`
)
.join("")}
.join("");
}
}
function renderSidebarPostList(data, view) {
const sections = Array.isArray(data.sections) ? data.sections : [];
const hasItems = sections.some((section) => (section.items || []).length > 0);
return `
${sections
.map(
(section) => `
<section class="sidebar-section">
<div class="sidebar-section-title">
<span class="section-icon status-${escapeHtmlAttribute(section.status || "draft")}">●</span>
<span data-testid="sidebar-section-title">${escapeHtml(tText(section.title))}</span>
<span class="sidebar-section-count">${escapeHtml(String(section.count || (section.items || []).length))}</span>
</div>
<div class="sidebar-list">
${(section.items || []).map((item) => renderSidebarPostItem(item, view)).join("")}
</div>
</section>
`
)
.join("")}
${hasItems ? "" : renderSidebarEmpty(data.empty_message || "No items")}
`;
}
function renderSidebarPostItem(item, view) {
const tabRef = currentTabRef();
const itemRoute = item.route || view.editor_route;
const tabId = tabIdForItem(item, itemRoute);
const active = tabRef && tabRef.type === itemRoute && tabRef.id === tabId;
const postType = getSidebarPostType(item.categories || []);
const languageBadge = Number(item.language_count) > 1
? `<span class="sidebar-item-language-badge" title="${escapeHtmlAttribute(String(item.language_count))}">${escapeHtml(String(item.language_count))}</span>`
: "";
return `
<button
class="sidebar-item sidebar-post-item post-type-${escapeHtmlAttribute(postType.type)} ${active ? "active" : ""}"
data-open-tab="${escapeHtmlAttribute(tabId)}"
data-open-route="${escapeHtmlAttribute(itemRoute)}"
data-open-title="${escapeHtmlAttribute(item.title || routeLabel(itemRoute))}"
type="button"
>
<span class="post-type-icon" title="${escapeHtmlAttribute(postType.type)}">${escapeHtml(postType.icon)}</span>
<span class="sidebar-item-content">
<span class="sidebar-item-title-row">
<span class="sidebar-item-title">${escapeHtml(item.title || "")}</span>
${languageBadge}
</span>
<span class="sidebar-item-meta">${escapeHtml(formatSidebarAbsoluteDate(item.meta_timestamp))}</span>
</span>
</button>
`;
}
function renderSidebarMediaGrid(data, view) {
const items = Array.isArray(data.items) ? data.items : [];
if (!items.length) {
return renderSidebarEmpty(data.empty_message || "No items");
}
return `
<div class="sidebar-list media-grid">
${items.map((item) => renderSidebarMediaItem(item, view)).join("")}
</div>
`;
}
function renderSidebarItem(item, view) {
function renderSidebarMediaItem(item, view) {
const tabRef = currentTabRef();
const itemRoute = item.route || view.editor_route;
const tabId = tabIdForItem(item, itemRoute);
@@ -170,19 +254,92 @@ function renderSidebarItem(item, view) {
return `
<button
class="sidebar-item ${active ? "active" : ""}"
data-open-tab="${tabId}"
data-open-route="${itemRoute}"
data-open-title="${escapeHtmlAttribute(tText(item.title))}"
class="media-item ${active ? "selected" : ""}"
data-open-tab="${escapeHtmlAttribute(tabId)}"
data-open-route="${escapeHtmlAttribute(itemRoute)}"
data-open-title="${escapeHtmlAttribute(item.title || routeLabel(itemRoute))}"
type="button"
title="${escapeHtmlAttribute(item.title || "") }"
>
<span class="media-thumbnail">${escapeHtml(mediaThumbnailGlyph(item.mime_type))}</span>
<span class="media-item-info">
<span class="media-item-name">${escapeHtml(item.title || "")}</span>
<span class="media-item-size">${escapeHtml(item.meta || "")}</span>
</span>
</button>
`;
}
function renderSidebarEntityList(data, view) {
const items = Array.isArray(data.items) ? data.items : [];
if (!items.length) {
return renderSidebarEmpty(data.empty_message || "No items");
}
return items.map((item) => renderSidebarEntityItem(item, view)).join("");
}
function renderSidebarEntityItem(item, view) {
const tabRef = currentTabRef();
const itemRoute = item.route || view.editor_route;
const tabId = tabIdForItem(item, itemRoute);
const active = tabRef && tabRef.type === itemRoute && tabRef.id === tabId;
const meta = item.updated_at ? formatSidebarRelativeDateMs(item.updated_at) : tText(item.meta || "");
return `
<button
class="chat-list-item ${active ? "active" : ""}"
data-open-tab="${escapeHtmlAttribute(tabId)}"
data-open-route="${escapeHtmlAttribute(itemRoute)}"
data-open-title="${escapeHtmlAttribute(item.title || routeLabel(itemRoute))}"
type="button"
>
<strong>${escapeHtml(tText(item.title))}</strong>
<span>${escapeHtml(tText(item.meta || view.label))}</span>
${item.badge ? `<span class="sidebar-badge">${escapeHtml(tText(item.badge))}</span>` : ""}
<span class="chat-item-content">
<span class="chat-item-title">${escapeHtml(item.title || "")}</span>
<span class="chat-item-date">${escapeHtml(meta || "")}</span>
</span>
</button>
`;
}
function renderSidebarNavList(data, view) {
const items = Array.isArray(data.items) ? data.items : [];
return `
<div class="settings-nav-list">
${items.map((item) => renderSidebarNavItem(item, view)).join("")}
</div>
`;
}
function renderSidebarNavItem(item, view) {
const itemRoute = item.route || view.editor_route;
const tabId = tabIdForItem(item, itemRoute);
const tabTitle = routeLabel(itemRoute);
return `
<button
class="settings-nav-entry"
data-open-tab="${escapeHtmlAttribute(tabId)}"
data-open-route="${escapeHtmlAttribute(itemRoute)}"
data-open-title="${escapeHtmlAttribute(tabTitle)}"
type="button"
>
<span class="settings-nav-entry-icon">${escapeHtml(item.icon || "")}</span>
<span>${escapeHtml(tText(item.title || ""))}</span>
</button>
`;
}
function renderSidebarEmpty(message) {
return `
<div class="sidebar-empty">
<p>${escapeHtml(tText(message))}</p>
</div>
`;
}
function renderTabs() {
const tabs = state.session.tabs;
const node = root.querySelector(".tab-bar");
@@ -361,7 +518,7 @@ function renderDashboard() {
<span class="recent-post-title">${escapeHtml(post.title || "")}</span>
<span class="recent-post-status status-${escapeHtmlAttribute(post.status || "draft")}">${escapeHtml(dashboardStatusLabel(post.status || "draft"))}</span>
<span class="recent-post-date">${escapeHtml(formatDashboardDate(post.updated_at))}</span>
</button>
if (route === "settings" || route === "tags" || route === "style") {
`
)
.join("")}
@@ -1146,6 +1303,7 @@ function closeActiveTab() {
}
const index = state.session.tabs.findIndex((tab) => tab.type === active.type && tab.id === active.id);
if (index < 0) {
return;
}
@@ -1225,8 +1383,16 @@ function activeItem() {
return null;
}
const sections = Object.values(bootstrap.content.sidebar).flatMap((view) => view.sections);
return sections.flatMap((section) => section.items).find((item) => tabIdForItem(item, item.route) === tab.id) || null;
const items = Object.values(bootstrap.content.sidebar).flatMap(flattenSidebarItems);
return items.find((item) => item.route === tab.type && tabIdForItem(item, item.route) === tab.id) || null;
}
function flattenSidebarItems(view) {
if (Array.isArray(view.sections)) {
return view.sections.flatMap((section) => section.items || []);
}
return Array.isArray(view.items) ? view.items : [];
}
function tabMetadata(tab) {
@@ -1442,7 +1608,7 @@ function formatPayloadValue(value) {
}
function tabIdForItem(item, route) {
if (route === "settings" || route === "tags") {
if (route === "settings" || route === "tags" || route === "style") {
return route;
}
@@ -1714,6 +1880,76 @@ function statusLabel(status) {
}
}
function getSidebarPostType(categories) {
const lowerCategories = (categories || []).map((category) => String(category).toLowerCase());
if (lowerCategories.includes("picture") || lowerCategories.includes("photo") || lowerCategories.includes("image")) {
return { icon: "🖼️", type: "picture" };
}
if (lowerCategories.includes("aside") || lowerCategories.includes("note") || lowerCategories.includes("quick")) {
return { icon: "📝", type: "aside" };
}
if (lowerCategories.includes("link") || lowerCategories.includes("bookmark")) {
return { icon: "🔗", type: "link" };
}
if (lowerCategories.includes("video")) {
return { icon: "🎬", type: "video" };
}
if (lowerCategories.includes("quote")) {
return { icon: "💬", type: "quote" };
}
return { icon: "📄", type: "article" };
}
function formatSidebarAbsoluteDate(timestamp) {
if (!timestamp) {
return "";
}
return new Intl.DateTimeFormat(formatLocaleFor(state.uiLanguage), {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(timestamp));
}
function formatSidebarRelativeDateMs(timestamp) {
if (!timestamp) {
return "";
}
const date = new Date(timestamp);
const now = new Date();
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return date.toLocaleTimeString(formatLocaleFor(state.uiLanguage), { hour: "numeric", minute: "2-digit" });
}
if (diffDays === 1) {
return t("sidebar.chat.yesterday");
}
if (diffDays < 7) {
return date.toLocaleDateString(formatLocaleFor(state.uiLanguage), { weekday: "short" });
}
return date.toLocaleDateString(formatLocaleFor(state.uiLanguage), { month: "short", day: "numeric" });
}
function mediaThumbnailGlyph(mimeType) {
if (String(mimeType || "").startsWith("image/")) {
return "🖼️";
}
return "📄";
}
function buildDashboardTagCloudItems(items) {
if (!Array.isArray(items) || !items.length) {
return [];

View File

@@ -53,11 +53,14 @@
"sidebar_visible": true,
"sidebar_width": 280,
"active_view": "posts",
"assistant_sidebar_visible": false,
"layout": "post_list",
"sections": [
"assistant_sidebar_width": 360,
"status": "draft",
"count": 1,
"panel": { "visible": false, "active_tab": "tasks" },
"tabs": [],
"active_tab": null,
{ "id": "post-welcome", "title": "Welcome to bDS2", "meta_timestamp": 1774972800000, "language_count": 1, "categories": ["note"], "route": "post" }
"dirty_tabs": []
},
"content": {
@@ -65,98 +68,71 @@
"posts": {
"title": "Posts",
"subtitle": "Drafts and publishing",
"sections": [
{
"title": "Drafts",
"items": [
{ "id": "post-welcome", "title": "Welcome to bDS2", "meta": "Updated today", "badge": "draft", "route": "post" }
]
}
]
"layout": "post_list",
"sections": []
},
"media": {
"title": "Media",
"subtitle": "Images and files",
"sections": [
{
"title": "Media",
"items": [
{ "id": "media-hero", "title": "hero-shot.jpg", "meta": "Image asset", "route": "media" }
]
}
"layout": "media_grid",
"items": [
{ "id": "media-hero", "title": "hero.jpg", "meta": "1.2 MB", "mime_type": "image/jpeg", "route": "media" }
]
},
"scripts": {
"title": "Scripts",
"subtitle": "Automation helpers",
"layout": "entity_list",
"items": [
{ "id": "script-sync", "title": "Sync tags", "updated_at": 1774800000000, "route": "scripts" }
]
},
"templates": {
"title": "Templates",
"subtitle": "Site rendering",
"layout": "entity_list",
"items": [
{ "id": "template-post", "title": "post.liquid", "updated_at": 1774713600000, "route": "templates" }
]
},
"tags": {
"title": "Tags",
"subtitle": "Tag management",
"layout": "nav_list",
"items": [
{ "id": "tags-cloud", "title": "Tag Cloud", "icon": "☁️", "route": "tags" },
{ "id": "tags-manage", "title": "Create / Edit", "icon": "✏️", "route": "tags" },
{ "id": "tags-merge", "title": "Merge Tags", "icon": "🔀", "route": "tags" }
]
},
"chat": {
"title": "Chat",
"subtitle": "AI conversations",
"layout": "entity_list",
"items": [
{ "id": "chat-planning", "title": "Planning session", "updated_at": 1774886400000, "route": "chat" }
]
},
"import": {
"title": "Import",
"subtitle": "Import definitions",
"layout": "entity_list",
"items": []
},
"git": {
"title": "Git",
"subtitle": "Working tree and history",
"layout": "entity_list",
"items": [
{ "id": "git-working-tree", "title": "Working tree", "meta": "Working tree and history", "route": "git_diff" }
]
},
"settings": {
"title": "Settings",
"subtitle": "Project preferences",
"sections": [
{
"title": "Settings",
"items": [
{ "id": "settings", "title": "Project", "meta": "Defaults and paths", "route": "settings" }
]
}
"layout": "nav_list",
"items": [
{ "id": "settings-project", "title": "Project", "icon": "📁", "route": "settings" },
{ "id": "settings-style", "title": "Style", "icon": "🎨", "route": "style" }
]
}
},
"dashboard": {
"title": "dashboard.title",
"subtitle": "dashboard.subtitle",
"post_stats": {
"total_posts": 42,
"draft_count": 18,
"published_count": 21,
"archived_count": 3
},
"media_stats": {
"media_count": 18,
"image_count": 15,
"total_bytes": 12884902
},
"timeline_entries": [
{ "year": 2025, "month": 11, "count": 2 },
{ "year": 2025, "month": 12, "count": 3 },
{ "year": 2026, "month": 1, "count": 5 },
{ "year": 2026, "month": 2, "count": 7 },
{ "year": 2026, "month": 3, "count": 9 },
{ "year": 2026, "month": 4, "count": 6 }
],
"tag_cloud_items": [
{ "tag": "launch", "count": 12, "color": "#2962ff" },
{ "tag": "writing", "count": 7, "color": "#00897b" },
{ "tag": "elixir", "count": 5, "color": "#e65100" }
],
"category_counts": [
{ "category": "notes", "count": 14 },
{ "category": "projects", "count": 8 }
],
"recent_posts": [
{ "id": "post-welcome", "title": "Welcome to bDS2", "status": "draft", "updated_at": 1774972800000 },
{ "id": "post-roadmap", "title": "Roadmap", "status": "published", "updated_at": 1774540800000 }
]
},
"assistant_cards": [
{ "label": "Desktop Runtime", "text": "Static bundle mirrors the desktop shell layout." }
],
"editor_meta": {
"dashboard": [
{ "label": "Status", "value": "Ready" }
]
}
},
"status": {
"left": { "running_task_message": "Static preview", "running_task_overflow": 0 },
"right": {
"post_count": "42 posts",
"media_count": "18 media",
"theme_badge": "desktop-shell",
"offline_mode": true,
"ui_language": "en",
"brand": "bDS"
}
}
}
</script>
<script src="./app.js"></script>
</body>
</html>
},

View File

@@ -159,6 +159,27 @@ defmodule BDS.UI.ShellTest do
assert css =~ ".recent-posts-list"
end
test "shell bootstrap and static bundle expose the old sidebar view contracts" do
html = ShellPage.render()
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
assert html =~ ~s("layout":"post_list")
assert html =~ ~s("layout":"media_grid")
assert html =~ ~s("layout":"entity_list")
assert html =~ ~s("layout":"nav_list")
assert js =~ "renderSidebarPostList"
assert js =~ "renderSidebarMediaGrid"
assert js =~ "renderSidebarEntityList"
assert js =~ "renderSidebarNavList"
assert css =~ ".sidebar-section-title"
assert css =~ ".media-grid"
assert css =~ ".chat-list-item"
assert css =~ ".settings-nav-entry"
end
test "static shell bundle exists for direct browser inspection" do
assert File.exists?("/Users/gb/Projects/bDS2/priv/ui/index.html")
assert File.exists?("/Users/gb/Projects/bDS2/priv/ui/app.css")