From 7ebea742a5db4b40dbe28594a541d46f7ca20a74 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sat, 25 Apr 2026 19:45:43 +0200 Subject: [PATCH] feat: dashboard implemented --- lib/bds/ui/dashboard.ex | 160 +++++++++++++++++++++ lib/bds/ui/shell_page.ex | 51 +++---- priv/i18n/locales/de.json | 20 +++ priv/i18n/locales/en.json | 20 +++ priv/i18n/locales/es.json | 20 +++ priv/i18n/locales/fr.json | 20 +++ priv/i18n/locales/it.json | 20 +++ priv/ui/app.css | 278 +++++++++++++++++++++++++++++++++++++ priv/ui/app.js | 274 +++++++++++++++++++++++++++++++++--- priv/ui/index.html | 39 +++++- test/bds/ui/shell_test.exs | 23 +++ 11 files changed, 863 insertions(+), 62 deletions(-) create mode 100644 lib/bds/ui/dashboard.ex diff --git a/lib/bds/ui/dashboard.ex b/lib/bds/ui/dashboard.ex new file mode 100644 index 0000000..22ab911 --- /dev/null +++ b/lib/bds/ui/dashboard.ex @@ -0,0 +1,160 @@ +defmodule BDS.UI.Dashboard do + @moduledoc false + + import Ecto.Query + + alias BDS.Media.Media + alias BDS.Posts.Post + alias BDS.Repo + alias BDS.Tags.Tag + + def snapshot(nil), do: empty_snapshot() + + def snapshot(project_id) when is_binary(project_id) do + posts = + Repo.all( + from post in Post, + where: post.project_id == ^project_id, + select: %{ + id: post.id, + title: post.title, + slug: post.slug, + status: post.status, + tags: post.tags, + categories: post.categories, + created_at: post.created_at, + updated_at: post.updated_at + } + ) + + media_items = + Repo.all( + from media in Media, + where: media.project_id == ^project_id, + select: %{mime_type: media.mime_type, size: media.size} + ) + + tag_colors = + Repo.all( + from tag in Tag, + where: tag.project_id == ^project_id, + select: %{name: tag.name, color: tag.color} + ) + |> Enum.reduce(%{}, fn %{name: name, color: color}, acc -> + if blank?(color), do: acc, else: Map.put(acc, name, color) + end) + + post_stats = post_stats(posts) + media_stats = media_stats(media_items) + tag_cloud_items = tag_cloud_items(posts, tag_colors) + category_counts = category_counts(posts) + + %{ + title: "dashboard.title", + subtitle: "dashboard.subtitle", + post_stats: post_stats, + media_stats: media_stats, + timeline_entries: timeline_entries(posts), + tag_cloud_items: tag_cloud_items, + category_counts: category_counts, + recent_posts: recent_posts(posts) + } + end + + def empty_snapshot do + %{ + title: "dashboard.title", + subtitle: "dashboard.subtitle", + post_stats: %{total_posts: 0, draft_count: 0, published_count: 0, archived_count: 0}, + media_stats: %{media_count: 0, image_count: 0, total_bytes: 0}, + timeline_entries: [], + tag_cloud_items: [], + category_counts: [], + recent_posts: [] + } + end + + defp post_stats(posts) do + Enum.reduce(posts, %{total_posts: 0, draft_count: 0, published_count: 0, archived_count: 0}, fn post, acc -> + acc + |> Map.update!(:total_posts, &(&1 + 1)) + |> increment_status(post.status) + end) + end + + defp media_stats(media_items) do + Enum.reduce(media_items, %{media_count: 0, image_count: 0, total_bytes: 0}, fn media, acc -> + acc + |> Map.update!(:media_count, &(&1 + 1)) + |> Map.update!(:total_bytes, &(&1 + (media.size || 0))) + |> maybe_increment_image_count(media.mime_type) + end) + end + + defp timeline_entries(posts) do + posts + |> Enum.reduce(%{}, fn post, acc -> + datetime = DateTime.from_unix!(post.created_at, :millisecond) + key = {datetime.year, datetime.month} + Map.update(acc, key, 1, &(&1 + 1)) + end) + |> Enum.map(fn {{year, month}, count} -> %{year: year, month: month, count: count} end) + |> Enum.sort_by(&{&1.year, &1.month}) + |> Enum.take(-12) + end + + defp tag_cloud_items(posts, tag_colors) do + posts + |> Enum.flat_map(&normalize_terms(&1.tags)) + |> Enum.frequencies() + |> Enum.map(fn {tag, count} -> %{tag: tag, count: count, color: Map.get(tag_colors, tag)} end) + |> Enum.sort_by(fn %{tag: tag, count: count} -> {-count, String.downcase(tag)} end) + end + + defp category_counts(posts) do + posts + |> Enum.flat_map(&normalize_terms(&1.categories)) + |> Enum.frequencies() + |> Enum.map(fn {category, count} -> %{category: category, count: count} end) + |> Enum.sort_by(fn %{category: category, count: count} -> {-count, String.downcase(category)} end) + end + + defp recent_posts(posts) do + posts + |> Enum.sort_by(& &1.updated_at, :desc) + |> Enum.take(5) + |> Enum.map(fn post -> + %{ + id: post.id, + title: display_title(post), + status: Atom.to_string(post.status), + updated_at: post.updated_at + } + end) + end + + defp normalize_terms(values) do + values + |> Kernel.||([]) + |> Enum.map(&to_string/1) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + end + + defp display_title(post) do + if blank?(post.title), do: post.slug || "", else: post.title + end + + defp increment_status(counts, :draft), do: Map.update!(counts, :draft_count, &(&1 + 1)) + defp increment_status(counts, :published), do: Map.update!(counts, :published_count, &(&1 + 1)) + defp increment_status(counts, :archived), do: Map.update!(counts, :archived_count, &(&1 + 1)) + defp increment_status(counts, _status), do: counts + + defp maybe_increment_image_count(counts, mime_type) when is_binary(mime_type) do + if String.starts_with?(mime_type, "image/"), do: Map.update!(counts, :image_count, &(&1 + 1)), else: counts + end + + defp maybe_increment_image_count(counts, _mime_type), do: counts + + defp blank?(value), do: value in [nil, ""] +end diff --git a/lib/bds/ui/shell_page.ex b/lib/bds/ui/shell_page.ex index 148c148..d7cc0a3 100644 --- a/lib/bds/ui/shell_page.ex +++ b/lib/bds/ui/shell_page.ex @@ -3,6 +3,7 @@ defmodule BDS.UI.ShellPage do alias BDS.I18n alias BDS.Projects + alias BDS.UI.Dashboard alias BDS.UI.MenuBar alias BDS.UI.Registry alias BDS.UI.Session @@ -54,6 +55,8 @@ defmodule BDS.UI.ShellPage do workbench = Workbench.new() task_status = BDS.Tasks.status_snapshot() ui_language = I18n.current_ui_locale() + projects = project_snapshot() + dashboard = dashboard_content(projects.active_project_id) %{ title: Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server", @@ -77,19 +80,19 @@ defmodule BDS.UI.ShellPage do default_sidebar_view: Atom.to_string(Registry.default_sidebar_view()) }, menu_groups: Enum.map(MenuBar.default_groups(), &encode_menu_group/1), - projects: project_snapshot(), + projects: projects, session: Session.serialize(workbench), task_status: task_status, content: %{ sidebar: sidebar_content(), - dashboard: dashboard_content(task_status), + dashboard: dashboard, assistant_cards: assistant_cards(), editor_meta: editor_meta(task_status) }, status: Workbench.status_bar(workbench, - post_count: 42, - media_count: 18, + post_count: dashboard.post_stats.total_posts, + media_count: dashboard.media_stats.media_count, theme_badge: "desktop-shell", ui_language: ui_language, offline_mode: true, @@ -227,25 +230,15 @@ defmodule BDS.UI.ShellPage do %{title: title, subtitle: subtitle, sections: [%{title: title, items: items}]} end - defp dashboard_content(task_status) do - %{ - title: "Dashboard", - subtitle: "Desktop workbench shell wired through Elixir", - summary_cards: [ - %{label: "Posts", value: "42", detail: "Across draft, published, and archive"}, - %{label: "Media", value: "18", detail: "Images and documents indexed"}, - %{ - label: "Tasks", - value: Integer.to_string(task_status.active_count), - detail: task_summary_detail(task_status) - } - ], - checklist: [ - "Native menu groups mirror the old application shell", - "Sidebar, tabs, panel, and assistant panes are inspectable DOM regions", - "Automation can boot the shell in a separate process and capture screenshots" - ] - } + defp dashboard_content(project_id) do + Dashboard.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 + + Dashboard.empty_snapshot() end defp assistant_cards do @@ -266,18 +259,6 @@ defmodule BDS.UI.ShellPage do } end - defp task_summary_detail(%{active_count: 0}), do: "No active background tasks" - - defp task_summary_detail(%{running_count: running, pending_count: pending}) do - segments = [] - segments = if running > 0, do: ["#{running} running" | segments], else: segments - segments = if pending > 0, do: ["#{pending} queued" | segments], else: segments - - segments - |> Enum.reverse() - |> Enum.join(", ") - end - defp normalize_view_label(:chat, _label), do: "Chat" defp normalize_view_label(:git, _label), do: "Git" defp normalize_view_label(_id, label), do: label diff --git a/priv/i18n/locales/de.json b/priv/i18n/locales/de.json index 8601520..383fe57 100644 --- a/priv/i18n/locales/de.json +++ b/priv/i18n/locales/de.json @@ -66,6 +66,26 @@ "Command failed with HTTP %{status}": "Befehl mit HTTP %{status} fehlgeschlagen", "Create Project": "Projekt erstellen", "Dashboard": "Instrumententafel", + "dashboard.postCount.one": "%{count} Beitrag", + "dashboard.postCount.other": "%{count} Beiträge", + "dashboard.section.categories": "Kategorien", + "dashboard.section.postsOverTime": "Beiträge im Zeitverlauf", + "dashboard.section.recentlyUpdated": "Kürzlich aktualisiert", + "dashboard.section.tags": "Schlagwörter", + "dashboard.stats.archived": "%{count} archiviert", + "dashboard.stats.categories": "%{count} Kategorien", + "dashboard.stats.drafts": "%{count} Entwürfe", + "dashboard.stats.images": "%{count} Bilder", + "dashboard.stats.mediaFiles": "Mediendateien", + "dashboard.stats.published": "%{count} veröffentlicht", + "dashboard.stats.tags": "Schlagwörter", + "dashboard.stats.totalPosts": "Beiträge gesamt", + "dashboard.status.archived": "Archiviert", + "dashboard.status.draft": "Entwurf", + "dashboard.status.published": "Veröffentlicht", + "dashboard.subtitle": "Überblick über deine Blog-Datenbank", + "dashboard.tagCloud.more": "+%{count} weitere", + "dashboard.title": "Übersicht", "Desktop Runtime": "Desktop-Laufzeit", "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 --git a/priv/i18n/locales/en.json b/priv/i18n/locales/en.json index 60be6e0..b00df97 100644 --- a/priv/i18n/locales/en.json +++ b/priv/i18n/locales/en.json @@ -66,6 +66,26 @@ "Command failed with HTTP %{status}": "Command failed with HTTP %{status}", "Create Project": "Create Project", "Dashboard": "Dashboard", + "dashboard.postCount.one": "%{count} post", + "dashboard.postCount.other": "%{count} posts", + "dashboard.section.categories": "Categories", + "dashboard.section.postsOverTime": "Posts Over Time", + "dashboard.section.recentlyUpdated": "Recently Updated", + "dashboard.section.tags": "Tags", + "dashboard.stats.archived": "%{count} archived", + "dashboard.stats.categories": "%{count} categories", + "dashboard.stats.drafts": "%{count} drafts", + "dashboard.stats.images": "%{count} images", + "dashboard.stats.mediaFiles": "Media Files", + "dashboard.stats.published": "%{count} published", + "dashboard.stats.tags": "Tags", + "dashboard.stats.totalPosts": "Total Posts", + "dashboard.status.archived": "Archived", + "dashboard.status.draft": "Draft", + "dashboard.status.published": "Published", + "dashboard.subtitle": "Overview of your blog database", + "dashboard.tagCloud.more": "+%{count} more", + "dashboard.title": "Dashboard", "Desktop Runtime": "Desktop Runtime", "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 --git a/priv/i18n/locales/es.json b/priv/i18n/locales/es.json index 9ae9918..a45b2f4 100644 --- a/priv/i18n/locales/es.json +++ b/priv/i18n/locales/es.json @@ -66,6 +66,26 @@ "Command failed with HTTP %{status}": "El comando falló con HTTP %{status}", "Create Project": "Crear proyecto", "Dashboard": "Panel", + "dashboard.postCount.one": "%{count} entrada", + "dashboard.postCount.other": "%{count} entradas", + "dashboard.section.categories": "Categorías", + "dashboard.section.postsOverTime": "Entradas a lo largo del tiempo", + "dashboard.section.recentlyUpdated": "Actualizadas recientemente", + "dashboard.section.tags": "Etiquetas", + "dashboard.stats.archived": "%{count} archivadas", + "dashboard.stats.categories": "%{count} categorías", + "dashboard.stats.drafts": "%{count} borradores", + "dashboard.stats.images": "%{count} imágenes", + "dashboard.stats.mediaFiles": "Archivos multimedia", + "dashboard.stats.published": "%{count} publicadas", + "dashboard.stats.tags": "Etiquetas", + "dashboard.stats.totalPosts": "Entradas totales", + "dashboard.status.archived": "Archivada", + "dashboard.status.draft": "Borrador", + "dashboard.status.published": "Publicada", + "dashboard.subtitle": "Resumen de la base de datos de tu blog", + "dashboard.tagCloud.more": "+%{count} más", + "dashboard.title": "Panel", "Desktop Runtime": "Entorno de escritorio", "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 --git a/priv/i18n/locales/fr.json b/priv/i18n/locales/fr.json index d753d86..a055480 100644 --- a/priv/i18n/locales/fr.json +++ b/priv/i18n/locales/fr.json @@ -66,6 +66,26 @@ "Command failed with HTTP %{status}": "La commande a échoué avec HTTP %{status}", "Create Project": "Créer un projet", "Dashboard": "Tableau de bord", + "dashboard.postCount.one": "%{count} article", + "dashboard.postCount.other": "%{count} articles", + "dashboard.section.categories": "Catégories", + "dashboard.section.postsOverTime": "Articles dans le temps", + "dashboard.section.recentlyUpdated": "Récemment mis à jour", + "dashboard.section.tags": "Étiquettes", + "dashboard.stats.archived": "%{count} archivés", + "dashboard.stats.categories": "%{count} catégories", + "dashboard.stats.drafts": "%{count} brouillons", + "dashboard.stats.images": "%{count} images", + "dashboard.stats.mediaFiles": "Fichiers média", + "dashboard.stats.published": "%{count} publiés", + "dashboard.stats.tags": "Étiquettes", + "dashboard.stats.totalPosts": "Articles au total", + "dashboard.status.archived": "Archivé", + "dashboard.status.draft": "Brouillon", + "dashboard.status.published": "Publié", + "dashboard.subtitle": "Aperçu de la base de données de votre blog", + "dashboard.tagCloud.more": "+%{count} de plus", + "dashboard.title": "Tableau de bord", "Desktop Runtime": "Exécution bureau", "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 --git a/priv/i18n/locales/it.json b/priv/i18n/locales/it.json index c4beb80..21ef4e8 100644 --- a/priv/i18n/locales/it.json +++ b/priv/i18n/locales/it.json @@ -66,6 +66,26 @@ "Command failed with HTTP %{status}": "Comando non riuscito con HTTP %{status}", "Create Project": "Crea progetto", "Dashboard": "Dashboard", + "dashboard.postCount.one": "%{count} post", + "dashboard.postCount.other": "%{count} post", + "dashboard.section.categories": "Categorie", + "dashboard.section.postsOverTime": "Post nel tempo", + "dashboard.section.recentlyUpdated": "Aggiornati di recente", + "dashboard.section.tags": "Tag", + "dashboard.stats.archived": "%{count} archiviati", + "dashboard.stats.categories": "%{count} categorie", + "dashboard.stats.drafts": "%{count} bozze", + "dashboard.stats.images": "%{count} immagini", + "dashboard.stats.mediaFiles": "File multimediali", + "dashboard.stats.published": "%{count} pubblicati", + "dashboard.stats.tags": "Tag", + "dashboard.stats.totalPosts": "Post totali", + "dashboard.status.archived": "Archiviato", + "dashboard.status.draft": "Bozza", + "dashboard.status.published": "Pubblicato", + "dashboard.subtitle": "Panoramica del database del tuo blog", + "dashboard.tagCloud.more": "+%{count} in più", + "dashboard.title": "Dashboard", "Desktop Runtime": "Runtime desktop", "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 --git a/priv/ui/app.css b/priv/ui/app.css index c1ac0be..5a5c82f 100644 --- a/priv/ui/app.css +++ b/priv/ui/app.css @@ -1,5 +1,6 @@ :root { --vscode-editor-background: #1e1e1e; + --vscode-editor-foreground: #cccccc; --vscode-sideBar-background: #252526; --vscode-activityBar-background: #333333; --vscode-panel-background: #1e1e1e; @@ -21,11 +22,15 @@ --vscode-sideBar-border: #80808059; --vscode-tab-border: #252526; --vscode-focusBorder: #007fd4; + --vscode-input-background: rgba(255, 255, 255, 0.06); + --vscode-input-border: rgba(255, 255, 255, 0.12); --vscode-list-hoverBackground: #2a2d2e; --vscode-list-activeSelectionBackground: #094771; --vscode-list-activeSelectionForeground: #ffffff; --vscode-activityBarBadge-background: #007acc; --vscode-activityBarBadge-foreground: #ffffff; + --vscode-testing-iconPassed: #73c991; + --vscode-editorWarning-foreground: #cca700; --sidebar-width: 280px; --assistant-width: 360px; color-scheme: dark; @@ -1172,4 +1177,277 @@ button { .dashboard-grid { grid-template-columns: 1fr; } +} + +.text-muted { + color: var(--vscode-descriptionForeground); +} + +.editor-empty { + flex: 1; + display: flex; + align-items: flex-start; + justify-content: center; + background-color: var(--vscode-editor-background); + overflow-y: auto; + padding: 40px 20px; +} + +.dashboard-content { + max-width: 720px; + width: 100%; +} + +.dashboard-content h1 { + font-size: 24px; + font-weight: 400; + margin: 0 0 4px; + color: var(--vscode-editor-foreground); +} + +.dashboard-content > .text-muted { + margin-bottom: 24px; + display: block; +} + +.dashboard-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 24px; +} + +.stat-card { + padding: 16px; + background-color: var(--vscode-sideBar-background); + border-radius: 6px; +} + +.stat-number { + font-size: 32px; + font-weight: 600; + color: var(--vscode-editor-foreground); + line-height: 1; + margin-bottom: 4px; +} + +.stat-label { + font-size: 12px; + color: var(--vscode-descriptionForeground); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 10px; +} + +.stat-breakdown { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.stat-tag { + font-size: 11px; + padding: 2px 8px; + border-radius: 3px; + background-color: var(--vscode-input-background); + color: var(--vscode-descriptionForeground); +} + +.stat-published { + color: var(--vscode-testing-iconPassed); +} + +.stat-draft { + color: var(--vscode-editorWarning-foreground); +} + +.stat-archived { + color: var(--vscode-descriptionForeground); +} + +.dashboard-section { + background-color: var(--vscode-sideBar-background); + border-radius: 6px; + padding: 16px; + margin-bottom: 12px; +} + +.dashboard-section h4 { + font-size: 11px; + font-weight: 600; + color: var(--vscode-descriptionForeground); + text-transform: uppercase; + letter-spacing: 0.5px; + margin: 0 0 12px; +} + +.timeline-chart { + display: flex; + align-items: flex-end; + gap: 4px; + height: 100px; +} + +.timeline-bar-container { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + height: 100%; +} + +.timeline-bar { + width: 100%; + max-width: 40px; + background-color: var(--vscode-activityBarBadge-background); + border-radius: 3px 3px 0 0; + margin-top: auto; + min-height: 4px; + position: relative; + transition: opacity 0.15s; +} + +.timeline-bar:hover { + opacity: 0.8; +} + +.timeline-bar-count { + position: absolute; + top: -16px; + left: 50%; + transform: translateX(-50%); + font-size: 10px; + color: var(--vscode-descriptionForeground); +} + +.timeline-bar-label { + display: flex; + flex-direction: column; + align-items: center; + font-size: 9px; + color: var(--vscode-descriptionForeground); + margin-top: 4px; + line-height: 1.15; +} + +.timeline-bar-label-month { + white-space: nowrap; +} + +.timeline-bar-label-year { + font-size: 8px; +} + +.tag-cloud { + display: flex; + flex-wrap: wrap; + gap: 6px 10px; + align-items: baseline; + line-height: 1.6; +} + +.dashboard-tag { + padding: 2px 8px; + border-radius: 10px; + background-color: var(--vscode-input-background); + color: var(--vscode-editor-foreground); + cursor: default; + transition: opacity 0.15s; + white-space: nowrap; +} + +.dashboard-tag:hover { + opacity: 0.75; +} + +.dashboard-tag.has-color { + border-radius: 12px; +} + +.dashboard-tag.has-color:hover { + opacity: 0.85; +} + +.tag-cloud-more { + font-size: 11px; +} + +.tag-count { + font-size: 10px; + opacity: 0.5; + margin-left: 2px; +} + +.dashboard-category { + font-size: 12px; + border: 1px solid var(--vscode-input-border); +} + +.recent-posts-list { + display: flex; + flex-direction: column; +} + +.recent-post-item { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 8px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + width: 100%; + border: none; + background: transparent; + text-align: left; + color: inherit; +} + +.recent-post-item:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.recent-post-title { + flex: 1; + color: var(--vscode-editor-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.recent-post-status { + font-size: 10px; + padding: 1px 6px; + border-radius: 3px; + background-color: var(--vscode-input-background); + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.recent-post-status.status-published { + color: var(--vscode-testing-iconPassed); +} + +.recent-post-status.status-draft { + color: var(--vscode-editorWarning-foreground); +} + +.recent-post-status.status-archived { + color: var(--vscode-descriptionForeground); +} + +.recent-post-date { + color: var(--vscode-descriptionForeground); + white-space: nowrap; +} + +@media (max-width: 820px) { + .dashboard-stats { + grid-template-columns: 1fr; + } + + .recent-post-item { + align-items: flex-start; + flex-wrap: wrap; + } } \ No newline at end of file diff --git a/priv/ui/app.js b/priv/ui/app.js index 03f281a..48e00e7 100644 --- a/priv/ui/app.js +++ b/priv/ui/app.js @@ -214,9 +214,15 @@ function renderTab(tab) { function renderEditor() { const route = currentRoute(); - const meta = currentEditorMeta(); const node = root.querySelector(".editor-shell"); + if (route === "dashboard") { + node.innerHTML = renderDashboard(); + return; + } + + const meta = currentEditorMeta(); + node.innerHTML = `
@@ -241,28 +247,148 @@ function renderEditor() { `; } +function renderDashboard() { + const dashboard = bootstrap.content.dashboard || {}; + const postStats = dashboard.post_stats || {}; + const mediaStats = dashboard.media_stats || {}; + const timelineEntries = Array.isArray(dashboard.timeline_entries) ? dashboard.timeline_entries : []; + const tagCloudItems = buildDashboardTagCloudItems(dashboard.tag_cloud_items || []); + const categoryCounts = Array.isArray(dashboard.category_counts) ? dashboard.category_counts : []; + const recentPosts = Array.isArray(dashboard.recent_posts) ? dashboard.recent_posts : []; + const meta = currentEditorMeta(); + const maxCount = Math.max(1, ...timelineEntries.map((entry) => Number(entry.count) || 0)); + + return ` +
+
+

${escapeHtml(t("dashboard.title"))}

+

${escapeHtml(t("dashboard.subtitle"))}

+ +
+
+
${escapeHtml(String(postStats.total_posts || 0))}
+
${escapeHtml(t("dashboard.stats.totalPosts"))}
+
+ ${escapeHtml(t("dashboard.stats.published", { count: postStats.published_count || 0 }))} + ${escapeHtml(t("dashboard.stats.drafts", { count: postStats.draft_count || 0 }))} + ${(postStats.archived_count || 0) > 0 ? `${escapeHtml(t("dashboard.stats.archived", { count: postStats.archived_count || 0 }))}` : ""} +
+
+
+
${escapeHtml(String(mediaStats.media_count || 0))}
+
${escapeHtml(t("dashboard.stats.mediaFiles"))}
+
+ ${escapeHtml(t("dashboard.stats.images", { count: mediaStats.image_count || 0 }))} + ${escapeHtml(formatBytes(mediaStats.total_bytes || 0))} +
+
+
+
${escapeHtml(String((dashboard.tag_cloud_items || []).length))}
+
${escapeHtml(t("dashboard.stats.tags"))}
+
+ ${escapeHtml(t("dashboard.stats.categories", { count: categoryCounts.length }))} +
+
+
+ + ${timelineEntries.length ? ` +
+

${escapeHtml(t("dashboard.section.postsOverTime"))}

+
+ ${timelineEntries + .map( + (entry) => ` +
+
+ ${escapeHtml(String(entry.count || 0))} +
+
+ ${escapeHtml(formatDashboardMonth(entry.year, entry.month))} + ${escapeHtml(String(entry.year || ""))} +
+
+ ` + ) + .join("")} +
+
+ ` : ""} + + ${tagCloudItems.length ? ` +
+

${escapeHtml(t("dashboard.section.tags"))}

+
+ ${tagCloudItems + .map((item) => `${escapeHtml(item.tag)}`) + .join("")} + ${(dashboard.tag_cloud_items || []).length > 40 ? `${escapeHtml(t("dashboard.tagCloud.more", { count: (dashboard.tag_cloud_items || []).length - 40 }))}` : ""} +
+
+ ` : ""} + + ${categoryCounts.length ? ` +
+

${escapeHtml(t("dashboard.section.categories"))}

+
+ ${categoryCounts + .map( + (category) => ` + + ${escapeHtml(category.category || "")} + ${escapeHtml(String(category.count || 0))} + + ` + ) + .join("")} +
+
+ ` : ""} + + ${recentPosts.length ? ` +
+

${escapeHtml(t("dashboard.section.recentlyUpdated"))}

+
+ ${recentPosts + .map( + (post) => ` + + ` + ) + .join("")} +
+
+ ` : ""} + + +
+
+ `; +} + function renderEditorBody(route) { const meta = currentTabMeta(); - if (route === "dashboard") { - const dashboard = bootstrap.content.dashboard; - return ` -
-
    - ${dashboard.summary_cards - .map((card) => `
  • ${escapeHtml(tText(card.label))}: ${escapeHtml(card.value)} ${escapeHtml(tText(card.detail))}
  • `) - .join("")} -
-
-
-

${escapeHtml(t("Workbench Notes"))}

-
    - ${dashboard.checklist.map((entry) => `
  • ${escapeHtml(tText(entry))}
  • `).join("")} -
-
- `; - } - if (meta?.payload) { return renderCommandPayload(route, meta.payload); } @@ -1588,6 +1714,114 @@ function statusLabel(status) { } } +function buildDashboardTagCloudItems(items) { + if (!Array.isArray(items) || !items.length) { + return []; + } + + const topItems = items + .slice() + .sort((left, right) => (Number(right.count) || 0) - (Number(left.count) || 0)) + .slice(0, 40); + + const counts = topItems.map((item) => Number(item.count) || 0); + const maxCount = Math.max(1, ...counts); + const minCount = Math.min(...counts); + const range = Math.max(1, maxCount - minCount); + + return topItems + .map((item) => ({ + ...item, + color: normalizeDashboardTagColor(item.color), + fontSize: 11 + (((Number(item.count) || 0) - minCount) / range) * 11, + })) + .sort((left, right) => String(left.tag || "").localeCompare(String(right.tag || ""))); +} + +function dashboardPostCountLabel(count) { + const normalizedCount = Number(count) || 0; + return t(normalizedCount === 1 ? "dashboard.postCount.one" : "dashboard.postCount.other", { count: normalizedCount }); +} + +function dashboardStatusLabel(status) { + const keys = { + draft: "dashboard.status.draft", + published: "dashboard.status.published", + archived: "dashboard.status.archived", + }; + + return keys[status] ? t(keys[status]) : tText(titleCase(status || "draft")); +} + +function formatDashboardMonth(year, month) { + return new Intl.DateTimeFormat(formatLocaleFor(state.uiLanguage), { month: "short" }).format(new Date(year, (month || 1) - 1, 1)); +} + +function formatDashboardDate(timestamp) { + if (!timestamp) { + return ""; + } + + return new Intl.DateTimeFormat(formatLocaleFor(state.uiLanguage)).format(new Date(timestamp)); +} + +function formatLocaleFor(language) { + const locales = { + de: "de-DE", + en: "en-US", + es: "es-ES", + fr: "fr-FR", + it: "it-IT", + }; + + return locales[language] || locales.en; +} + +function formatBytes(bytes) { + const normalizedBytes = Number(bytes) || 0; + + if (normalizedBytes === 0) { + return "0 B"; + } + + const units = ["B", "KB", "MB", "GB"]; + const unitIndex = Math.min(Math.floor(Math.log(normalizedBytes) / Math.log(1024)), units.length - 1); + const value = normalizedBytes / Math.pow(1024, unitIndex); + return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; +} + +function renderDashboardTagStyle(item) { + const declarations = [`font-size: ${(item.fontSize || 11).toFixed(1)}px;`]; + + if (item.color) { + declarations.push(`background-color: ${item.color};`); + declarations.push(`color: ${dashboardContrastColor(item.color)};`); + } + + return declarations.join(" "); +} + +function normalizeDashboardTagColor(color) { + if (typeof color !== "string") { + return null; + } + + const trimmed = color.trim(); + return /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(trimmed) ? trimmed : null; +} + +function dashboardContrastColor(hexColor) { + const normalized = hexColor.length === 4 + ? `#${hexColor[1]}${hexColor[1]}${hexColor[2]}${hexColor[2]}${hexColor[3]}${hexColor[3]}` + : hexColor; + + const red = Number.parseInt(normalized.slice(1, 3), 16); + const green = Number.parseInt(normalized.slice(3, 5), 16); + const blue = Number.parseInt(normalized.slice(5, 7), 16); + const luminance = (red * 299 + green * 587 + blue * 114) / 1000; + return luminance >= 140 ? "#111111" : "#f5f5f5"; +} + function escapeHtml(value) { return String(value) .replaceAll("&", "&") diff --git a/priv/ui/index.html b/priv/ui/index.html index b679ef4..d0c9a37 100644 --- a/priv/ui/index.html +++ b/priv/ui/index.html @@ -100,14 +100,39 @@ } }, "dashboard": { - "title": "Dashboard", - "subtitle": "Static shell bundle for direct inspection", - "summary_cards": [ - { "label": "Posts", "value": "42", "detail": "Drafts, published, archive" } + "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 } ], - "checklist": [ - "Static bundle is valid HTML", - "Shell assets render without duplicated bootstrap code" + "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": [ diff --git a/test/bds/ui/shell_test.exs b/test/bds/ui/shell_test.exs index 830f233..0e4b64d 100644 --- a/test/bds/ui/shell_test.exs +++ b/test/bds/ui/shell_test.exs @@ -136,6 +136,29 @@ defmodule BDS.UI.ShellTest do assert html =~ ~s("Assistant":"Assistent") end + test "shell bootstrap and static bundle expose the old dashboard sections" 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("timeline_entries") + assert html =~ ~s("tag_cloud_items") + assert html =~ ~s("category_counts") + assert html =~ ~s("recent_posts") + + assert js =~ "dashboard-content" + assert js =~ "dashboard-stats" + assert js =~ "timeline-chart" + assert js =~ "tag-cloud" + assert js =~ "recent-posts-list" + + assert css =~ ".dashboard-content" + assert css =~ ".dashboard-stats" + assert css =~ ".timeline-chart" + assert css =~ ".tag-cloud" + assert css =~ ".recent-posts-list" + 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")