From 55b30716966909cd14ef3ff557acec015a054bdb Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sat, 25 Apr 2026 20:26:55 +0200 Subject: [PATCH] feat: first take on sidebars --- lib/bds/ui/shell_page.ex | 76 ++-------- lib/bds/ui/sidebar.ex | 295 +++++++++++++++++++++++++++++++++++++ priv/i18n/locales/de.json | 19 +++ priv/i18n/locales/en.json | 19 +++ priv/i18n/locales/es.json | 19 +++ priv/i18n/locales/fr.json | 19 +++ priv/i18n/locales/it.json | 19 +++ priv/ui/app.css | 216 +++++++++++++++++++++++++++ priv/ui/app.js | 266 +++++++++++++++++++++++++++++++-- priv/ui/index.html | 150 ++++++++----------- test/bds/ui/shell_test.exs | 21 +++ 11 files changed, 951 insertions(+), 168 deletions(-) create mode 100644 lib/bds/ui/sidebar.ex diff --git a/lib/bds/ui/shell_page.ex b/lib/bds/ui/shell_page.ex index d7cc0a3..dfae62b 100644 --- a/lib/bds/ui/shell_page.ex +++ b/lib/bds/ui/shell_page.ex @@ -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 diff --git a/lib/bds/ui/sidebar.ex b/lib/bds/ui/sidebar.ex new file mode 100644 index 0000000..448032a --- /dev/null +++ b/lib/bds/ui/sidebar.ex @@ -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 diff --git a/priv/i18n/locales/de.json b/priv/i18n/locales/de.json index 383fe57..62c6de7 100644 --- a/priv/i18n/locales/de.json +++ b/priv/i18n/locales/de.json @@ -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", diff --git a/priv/i18n/locales/en.json b/priv/i18n/locales/en.json index b00df97..32ef9d0 100644 --- a/priv/i18n/locales/en.json +++ b/priv/i18n/locales/en.json @@ -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", diff --git a/priv/i18n/locales/es.json b/priv/i18n/locales/es.json index a45b2f4..401d396 100644 --- a/priv/i18n/locales/es.json +++ b/priv/i18n/locales/es.json @@ -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", diff --git a/priv/i18n/locales/fr.json b/priv/i18n/locales/fr.json index a055480..698934a 100644 --- a/priv/i18n/locales/fr.json +++ b/priv/i18n/locales/fr.json @@ -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 d’automatisation", "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 d’import", + "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 l’atelier bureau est acheminé via le shell Elixir.", "Desktop workbench shell wired through Elixir": "Shell d’atelier bureau câblé via Elixir", "Diff Reports": "Rapports de diff", diff --git a/priv/i18n/locales/it.json b/priv/i18n/locales/it.json index 21ef4e8..0fa6f49 100644 --- a/priv/i18n/locales/it.json +++ b/priv/i18n/locales/it.json @@ -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", diff --git a/priv/ui/app.css b/priv/ui/app.css index 5a5c82f..fba482a 100644 --- a/priv/ui/app.css +++ b/priv/ui/app.css @@ -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; + } } \ No newline at end of file diff --git a/priv/ui/app.js b/priv/ui/app.js index 48e00e7..9d14810 100644 --- a/priv/ui/app.js +++ b/priv/ui/app.js @@ -144,7 +144,23 @@ function renderSidebar() { + `; +} + +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) => ` ` ) - .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) => ` + + ` + ) + .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 + ? `${escapeHtml(String(item.language_count))}` + : ""; + + return ` + + `; +} + +function renderSidebarMediaGrid(data, view) { + const items = Array.isArray(data.items) ? data.items : []; + + if (!items.length) { + return renderSidebarEmpty(data.empty_message || "No items"); + } + + return ` + `; } -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 ` + `; +} + +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 ` + `; } +function renderSidebarNavList(data, view) { + const items = Array.isArray(data.items) ? data.items : []; + + return ` +
+ ${items.map((item) => renderSidebarNavItem(item, view)).join("")} +
+ `; +} + +function renderSidebarNavItem(item, view) { + const itemRoute = item.route || view.editor_route; + const tabId = tabIdForItem(item, itemRoute); + const tabTitle = routeLabel(itemRoute); + + return ` + + `; +} + +function renderSidebarEmpty(message) { + return ` + + `; +} + function renderTabs() { const tabs = state.session.tabs; const node = root.querySelector(".tab-bar"); @@ -361,7 +518,7 @@ function renderDashboard() { ${escapeHtml(post.title || "")} ${escapeHtml(dashboardStatusLabel(post.status || "draft"))} ${escapeHtml(formatDashboardDate(post.updated_at))} - + 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 []; diff --git a/priv/ui/index.html b/priv/ui/index.html index d0c9a37..736ba16 100644 --- a/priv/ui/index.html +++ b/priv/ui/index.html @@ -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" - } - } - } - - - - \ No newline at end of file + }, \ No newline at end of file diff --git a/test/bds/ui/shell_test.exs b/test/bds/ui/shell_test.exs index 0e4b64d..f759a3c 100644 --- a/test/bds/ui/shell_test.exs +++ b/test/bds/ui/shell_test.exs @@ -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")