feat: more UI cleanup

This commit is contained in:
2026-04-24 18:34:01 +02:00
parent 6824b89691
commit d4b0213a55
6 changed files with 407 additions and 105 deletions

View File

@@ -10,10 +10,11 @@ defmodule BDS.UI.ShellPage do
def render do def render do
bootstrap = bootstrap() bootstrap = bootstrap()
ui_language = get_in(bootstrap, [:i18n, :ui_language]) || "en"
[ [
"<!DOCTYPE html>", "<!DOCTYPE html>",
"<html lang=\"en\">", "<html lang=\"#{ui_language}\">",
"<head>", "<head>",
" <meta charset=\"utf-8\">", " <meta charset=\"utf-8\">",
" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">", " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
@@ -58,6 +59,10 @@ defmodule BDS.UI.ShellPage do
title: Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server", title: Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server",
i18n: %{ i18n: %{
ui_language: ui_language, ui_language: ui_language,
catalogs:
Enum.into(I18n.supported_languages(), %{}, fn language ->
{language.code, I18n.get_ui_translations(language.code)}
end),
supported_ui_languages: supported_ui_languages:
Enum.map(I18n.supported_languages(), fn language -> Enum.map(I18n.supported_languages(), fn language ->
%{ %{

View File

@@ -183,7 +183,9 @@ defmodule BDS.UI.Workbench do
right: %{ right: %{
post_status: post_status(state, Keyword.get(opts, :active_post_status)), post_status: post_status(state, Keyword.get(opts, :active_post_status)),
post_count: "#{Keyword.get(opts, :post_count, 0)} posts", post_count: "#{Keyword.get(opts, :post_count, 0)} posts",
post_count_value: Keyword.get(opts, :post_count, 0),
media_count: "#{Keyword.get(opts, :media_count, 0)} media", media_count: "#{Keyword.get(opts, :media_count, 0)} media",
media_count_value: Keyword.get(opts, :media_count, 0),
token_usage: token_usage(state, Keyword.get(opts, :token_usage)), token_usage: token_usage(state, Keyword.get(opts, :token_usage)),
theme_badge: Keyword.get(opts, :theme_badge, "default"), theme_badge: Keyword.get(opts, :theme_badge, "default"),
offline_mode: Keyword.get(opts, :offline_mode, false), offline_mode: Keyword.get(opts, :offline_mode, false),
@@ -265,4 +267,4 @@ defmodule BDS.UI.Workbench do
defp clamp_sidebar_width(width), do: max(200, min(width, 500)) defp clamp_sidebar_width(width), do: max(200, min(width, 500))
defp clamp_assistant_sidebar_width(width), do: max(280, min(width, 640)) defp clamp_assistant_sidebar_width(width), do: max(280, min(width, 640))
end end

View File

@@ -44,5 +44,122 @@
"render.taxonomy.ariaLabel": "Taxonomie", "render.taxonomy.ariaLabel": "Taxonomie",
"render.video.vimeoTitle": "Vimeo-Video", "render.video.vimeoTitle": "Vimeo-Video",
"render.video.youtubeTitle": "YouTube-Video", "render.video.youtubeTitle": "YouTube-Video",
"sidebar.chat.yesterday": "Gestern" "sidebar.chat.yesterday": "Gestern",
"%{count} media": "%{count} Medien",
"%{count} posts": "%{count} Beiträge",
"2 langs": "2 Sprachen",
"AI Assistant": "KI-Assistent",
"Across draft, published, and archive": "Über Entwürfe, veröffentlichte Beiträge und Archiv verteilt",
"Activated %{name}": "%{name} aktiviert",
"Archived": "Archiviert",
"Archived Jan 12, 2026": "Archiviert am 12. Jan. 2026",
"Assistant": "Assistent",
"Automatic AI actions stay gated by airplane mode.": "Automatische KI-Aktionen bleiben durch den Flugmodus gesperrt.",
"Automation can boot the shell in a separate process and capture screenshots": "Die Automatisierung kann die Shell in einem separaten Prozess starten und Screenshots aufnehmen",
"Blog": "Blog",
"Calendar regeneration is not wired yet, but the base shell now surfaces the command and keeps the Output tab selectable.": "Die Kalender-Neuerstellung ist noch nicht verdrahtet, aber die Basisshell zeigt den Befehl jetzt an und hält den Ausgabe-Tab auswählbar.",
"Chat": "Chat",
"Close %{title}": "%{title} schließen",
"Close tab": "Tab schließen",
"Command completed": "Befehl abgeschlossen",
"Command failed": "Befehl fehlgeschlagen",
"Command failed with HTTP %{status}": "Befehl mit HTTP %{status} fehlgeschlagen",
"Create Project": "Projekt erstellen",
"Dashboard": "Instrumententafel",
"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 Reports": "Diff-Berichte",
"Diffs": "Differenzen",
"Documentation": "Dokumentation",
"Drafts": "Entwürfe",
"Drafts, published entries, and archive history": "Entwürfe, veröffentlichte Einträge und Archivverlauf",
"Edit": "Bearbeiten",
"Extra": "Zusätzlich",
"Extra Pages": "Zusätzliche Seiten",
"File": "Datei",
"Filesystem Sync": "Dateisystem-Abgleich",
"Fill Missing Translations": "Fehlende Übersetzungen ergänzen",
"Find Duplicates": "Duplikate finden",
"Git": "Git",
"Git Log": "Git-Protokoll",
"Help": "Hilfe",
"Idle": "Leerlauf",
"Images and documents indexed": "Bilder und Dokumente indexiert",
"Import": "Importieren",
"Launch plan": "Startplan",
"Main Language": "Hauptsprache",
"Media": "Medien",
"Menu": "Menü",
"Metadata": "Metadaten",
"Metadata Diff": "Metadaten-Diff",
"Metadata flush, diffing, and rebuild hooks still need editor wiring.": "Metadaten-Schreiben, Diffing und Rebuild-Hooks brauchen noch die Editor-Anbindung.",
"Missing": "Fehlend",
"Missing Pages": "Fehlende Seiten",
"Missing Translations": "Fehlende Übersetzungen",
"Mode": "Modus",
"Native menu groups mirror the old application shell": "Native Menügruppen spiegeln die alte Anwendungsshell wider",
"New Project": "Neues Projekt",
"New project name": "Neuer Projektname",
"No active background tasks": "Keine aktiven Hintergrundaufgaben",
"No background tasks running": "Keine Hintergrundaufgaben aktiv",
"No items": "Keine Einträge",
"No missing pages": "Keine fehlenden Seiten",
"No orphan translation files": "Keine verwaisten Übersetzungsdateien",
"No shell output yet": "Noch keine Shell-Ausgabe",
"Offline": "Offline",
"Offline Gate": "Offline-Sperre",
"Open": "Öffnen",
"Open Data Folder": "Datenordner öffnen",
"Open in Browser": "Im Browser öffnen",
"Opened URL": "URL geöffnet",
"Orphan Files": "Verwaiste Dateien",
"Orphan Reports": "Berichte zu verwaisten Dateien",
"Orphans": "Verwaiste",
"Output": "Ausgabe",
"Pages": "Seiten",
"Pairs": "Paare",
"Post": "Beitrag",
"Posts": "Beiträge",
"Preview": "Vorschau",
"Projects": "Projekte",
"Published": "Veröffentlicht",
"Published Feb 10, 2026": "Veröffentlicht am 10. Feb. 2026",
"Queued": "In Warteschlange",
"Regenerate Calendar": "Kalender neu erzeugen",
"Retrospective": "Rückblick",
"Roadmap": "Fahrplan",
"Running": "Läuft",
"Script": "Skript",
"Scripts": "Skripte",
"Select Project": "Projekt auswählen",
"Settings": "Einstellungen",
"Sidebar, tabs, panel, and assistant panes are inspectable DOM regions": "Seitenleiste, Tabs, Panel und Assistentenbereiche sind als DOM-Regionen inspizierbar",
"Site Validation": "Website-Validierung",
"Source Control": "Quellcodeverwaltung",
"Stale": "Veraltet",
"Stale Pages": "Veraltete Seiten",
"Status": "Status",
"Style": "Stil",
"Switch project": "Projekt wechseln",
"Tags": "Tags",
"Tasks": "Aufgaben",
"Template": "Vorlage",
"Templates": "Vorlagen",
"The app window is now served from the Elixir shell renderer.": "Das App-Fenster wird jetzt vom Elixir-Shell-Renderer ausgeliefert.",
"The shared lower panel is available for tasks, output, git details, and editor-specific diagnostics.": "Das gemeinsame untere Panel steht für Aufgaben, Ausgabe, Git-Details und editorbezogene Diagnosen bereit.",
"Toggle assistant": "Assistent umschalten",
"Toggle offline mode": "Offline-Modus umschalten",
"Toggle panel": "Panel umschalten",
"Toggle sidebar": "Seitenleiste umschalten",
"Translation fill is not wired yet, but the command is now routed into Output instead of being ignored.": "Das Ergänzen fehlender Übersetzungen ist noch nicht verdrahtet, aber der Befehl wird jetzt in die Ausgabe geleitet statt ignoriert zu werden.",
"Translations": "Übersetzungen",
"UI": "UI",
"Updated today": "Heute aktualisiert",
"Updated yesterday": "Gestern aktualisiert",
"Upload Site": "Website hochladen",
"View": "Ansicht",
"Welcome to bDS2": "Willkommen bei bDS2",
"Workbench Notes": "Workbench-Hinweise",
"Working tree integration is not wired yet in the shell, but the tab is selectable and ready for command output.": "Die Integration des Arbeitsverzeichnisses ist in der Shell noch nicht verdrahtet, aber der Tab ist auswählbar und bereit für Befehlsausgaben."
} }

View File

@@ -44,5 +44,122 @@
"render.taxonomy.ariaLabel": "Taxonomy", "render.taxonomy.ariaLabel": "Taxonomy",
"render.video.vimeoTitle": "Vimeo video", "render.video.vimeoTitle": "Vimeo video",
"render.video.youtubeTitle": "YouTube video", "render.video.youtubeTitle": "YouTube video",
"sidebar.chat.yesterday": "Yesterday" "sidebar.chat.yesterday": "Yesterday",
"%{count} media": "%{count} media",
"%{count} posts": "%{count} posts",
"2 langs": "2 langs",
"AI Assistant": "AI Assistant",
"Across draft, published, and archive": "Across draft, published, and archive",
"Activated %{name}": "Activated %{name}",
"Archived": "Archived",
"Archived Jan 12, 2026": "Archived Jan 12, 2026",
"Assistant": "Assistant",
"Automatic AI actions stay gated by airplane mode.": "Automatic AI actions stay gated by airplane mode.",
"Automation can boot the shell in a separate process and capture screenshots": "Automation can boot the shell in a separate process and capture screenshots",
"Blog": "Blog",
"Calendar regeneration is not wired yet, but the base shell now surfaces the command and keeps the Output tab selectable.": "Calendar regeneration is not wired yet, but the base shell now surfaces the command and keeps the Output tab selectable.",
"Chat": "Chat",
"Close %{title}": "Close %{title}",
"Close tab": "Close tab",
"Command completed": "Command completed",
"Command failed": "Command failed",
"Command failed with HTTP %{status}": "Command failed with HTTP %{status}",
"Create Project": "Create Project",
"Dashboard": "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 Reports": "Diff Reports",
"Diffs": "Diffs",
"Documentation": "Documentation",
"Drafts": "Drafts",
"Drafts, published entries, and archive history": "Drafts, published entries, and archive history",
"Edit": "Edit",
"Extra": "Extra",
"Extra Pages": "Extra Pages",
"File": "File",
"Filesystem Sync": "Filesystem Sync",
"Fill Missing Translations": "Fill Missing Translations",
"Find Duplicates": "Find Duplicates",
"Git": "Git",
"Git Log": "Git Log",
"Help": "Help",
"Idle": "Idle",
"Images and documents indexed": "Images and documents indexed",
"Import": "Import",
"Launch plan": "Launch plan",
"Main Language": "Main Language",
"Media": "Media",
"Menu": "Menu",
"Metadata": "Metadata",
"Metadata Diff": "Metadata Diff",
"Metadata flush, diffing, and rebuild hooks still need editor wiring.": "Metadata flush, diffing, and rebuild hooks still need editor wiring.",
"Missing": "Missing",
"Missing Pages": "Missing Pages",
"Missing Translations": "Missing Translations",
"Mode": "Mode",
"Native menu groups mirror the old application shell": "Native menu groups mirror the old application shell",
"New Project": "New Project",
"New project name": "New project name",
"No active background tasks": "No active background tasks",
"No background tasks running": "No background tasks running",
"No items": "No items",
"No missing pages": "No missing pages",
"No orphan translation files": "No orphan translation files",
"No shell output yet": "No shell output yet",
"Offline": "Offline",
"Offline Gate": "Offline Gate",
"Open": "Open",
"Open Data Folder": "Open Data Folder",
"Open in Browser": "Open in Browser",
"Opened URL": "Opened URL",
"Orphan Files": "Orphan Files",
"Orphan Reports": "Orphan Reports",
"Orphans": "Orphans",
"Output": "Output",
"Pages": "Pages",
"Pairs": "Pairs",
"Post": "Post",
"Posts": "Posts",
"Preview": "Preview",
"Projects": "Projects",
"Published": "Published",
"Published Feb 10, 2026": "Published Feb 10, 2026",
"Queued": "Queued",
"Regenerate Calendar": "Regenerate Calendar",
"Retrospective": "Retrospective",
"Roadmap": "Roadmap",
"Running": "Running",
"Script": "Script",
"Scripts": "Scripts",
"Select Project": "Select Project",
"Settings": "Settings",
"Sidebar, tabs, panel, and assistant panes are inspectable DOM regions": "Sidebar, tabs, panel, and assistant panes are inspectable DOM regions",
"Site Validation": "Site Validation",
"Source Control": "Source Control",
"Stale": "Stale",
"Stale Pages": "Stale Pages",
"Status": "Status",
"Style": "Style",
"Switch project": "Switch project",
"Tags": "Tags",
"Tasks": "Tasks",
"Template": "Template",
"Templates": "Templates",
"The app window is now served from the Elixir shell renderer.": "The app window is now served from the Elixir shell renderer.",
"The shared lower panel is available for tasks, output, git details, and editor-specific diagnostics.": "The shared lower panel is available for tasks, output, git details, and editor-specific diagnostics.",
"Toggle assistant": "Toggle assistant",
"Toggle offline mode": "Toggle offline mode",
"Toggle panel": "Toggle panel",
"Toggle sidebar": "Toggle sidebar",
"Translation fill is not wired yet, but the command is now routed into Output instead of being ignored.": "Translation fill is not wired yet, but the command is now routed into Output instead of being ignored.",
"Translations": "Translations",
"UI": "UI",
"Updated today": "Updated today",
"Updated yesterday": "Updated yesterday",
"Upload Site": "Upload Site",
"View": "View",
"Welcome to bDS2": "Welcome to bDS2",
"Workbench Notes": "Workbench Notes",
"Working tree integration is not wired yet in the shell, but the tab is selectable and ready for command output.": "Working tree integration is not wired yet in the shell, but the tab is selectable and ready for command output."
} }

View File

@@ -23,6 +23,25 @@ const state = {
tabMeta: {}, tabMeta: {},
}; };
function translationsForLanguage(language) {
return bootstrap.i18n?.catalogs?.[language] || bootstrap.i18n?.catalogs?.en || {};
}
function t(key, bindings = {}) {
const catalog = translationsForLanguage(state.uiLanguage);
let text = catalog[key] || key;
Object.entries(bindings).forEach(([binding, value]) => {
text = text.replaceAll(`%{${binding}}`, String(value));
});
return text;
}
function tText(value, bindings = {}) {
return t(String(value), bindings);
}
bindNativeMenuBridge(); bindNativeMenuBridge();
bindGlobalHotkeys(); bindGlobalHotkeys();
scheduleTaskPolling(); scheduleTaskPolling();
@@ -51,23 +70,23 @@ function renderTitlebar() {
root.querySelector(".window-titlebar").innerHTML = ` root.querySelector(".window-titlebar").innerHTML = `
<div class="${menuBarClass}"> <div class="${menuBarClass}">
${bootstrap.menu_groups ${bootstrap.menu_groups
.map((group) => `<button class="window-titlebar-menu-button" type="button">${escapeHtml(group.label)}</button>`) .map((group) => `<button class="window-titlebar-menu-button" type="button">${escapeHtml(tText(group.label))}</button>`)
.join("")} .join("")}
</div> </div>
<div class="window-titlebar-drag-region"></div> <div class="window-titlebar-drag-region"></div>
<div class="window-titlebar-title" data-testid="window-title">${escapeHtml(bootstrap.title)}</div> <div class="window-titlebar-title" data-testid="window-title">${escapeHtml(bootstrap.title)}</div>
<div class="window-titlebar-actions"> <div class="window-titlebar-actions">
${renderTitlebarAction("toggle-sidebar", "toggle-sidebar", "Toggle sidebar", ` ${renderTitlebarAction("toggle-sidebar", "toggle-sidebar", t("Toggle sidebar"), `
<span class="window-titlebar-sidebar-icon ${state.session.sidebar_visible ? "is-active" : "is-inactive"}"> <span class="window-titlebar-sidebar-icon ${state.session.sidebar_visible ? "is-active" : "is-inactive"}">
<span class="window-titlebar-sidebar-pane"></span> <span class="window-titlebar-sidebar-pane"></span>
</span> </span>
`)} `)}
${renderTitlebarAction("toggle-panel", "toggle-panel", "Toggle panel", ` ${renderTitlebarAction("toggle-panel", "toggle-panel", t("Toggle panel"), `
<span class="window-titlebar-panel-icon ${state.session.panel.visible ? "is-active" : "is-inactive"}"> <span class="window-titlebar-panel-icon ${state.session.panel.visible ? "is-active" : "is-inactive"}">
<span class="window-titlebar-panel-pane"></span> <span class="window-titlebar-panel-pane"></span>
</span> </span>
`)} `)}
${renderTitlebarAction("toggle-assistant-sidebar", "toggle-assistant", "Toggle assistant", ` ${renderTitlebarAction("toggle-assistant-sidebar", "toggle-assistant", t("Toggle assistant"), `
<span class="window-titlebar-assistant-icon ${state.session.assistant_sidebar_visible ? "is-active" : "is-inactive"}"> <span class="window-titlebar-assistant-icon ${state.session.assistant_sidebar_visible ? "is-active" : "is-inactive"}">
<span class="window-titlebar-assistant-pane"></span> <span class="window-titlebar-assistant-pane"></span>
</span> </span>
@@ -104,8 +123,8 @@ function renderActivityButton(view) {
data-active="${String(active)}" data-active="${String(active)}"
data-testid="activity-button" data-testid="activity-button"
type="button" type="button"
aria-label="${escapeHtml(view.label)}" aria-label="${escapeHtml(tText(view.label))}"
title="${escapeHtml(view.label)}" title="${escapeHtml(tText(view.label))}"
> >
${activityIcon(view.id)} ${activityIcon(view.id)}
</button> </button>
@@ -119,8 +138,8 @@ function renderSidebar() {
root.querySelector(".sidebar").innerHTML = ` root.querySelector(".sidebar").innerHTML = `
<div class="sidebar-header"> <div class="sidebar-header">
<div class="sidebar-title-row"> <div class="sidebar-title-row">
<strong>${escapeHtml(data.title)}</strong> <strong>${escapeHtml(tText(data.title))}</strong>
<span class="sidebar-subtitle">${escapeHtml(data.subtitle)}</span> <span class="sidebar-subtitle">${escapeHtml(tText(data.subtitle))}</span>
</div> </div>
</div> </div>
<div class="sidebar-content"> <div class="sidebar-content">
@@ -129,7 +148,7 @@ function renderSidebar() {
(section) => ` (section) => `
<section class="sidebar-section"> <section class="sidebar-section">
<div class="sidebar-section-header"> <div class="sidebar-section-header">
<span data-testid="sidebar-section-title">${escapeHtml(section.title)}</span> <span data-testid="sidebar-section-title">${escapeHtml(tText(section.title))}</span>
</div> </div>
<div class="sidebar-section-items"> <div class="sidebar-section-items">
${section.items.map((item) => renderSidebarItem(item, view)).join("")} ${section.items.map((item) => renderSidebarItem(item, view)).join("")}
@@ -153,12 +172,12 @@ function renderSidebarItem(item, view) {
class="sidebar-item ${active ? "active" : ""}" class="sidebar-item ${active ? "active" : ""}"
data-open-tab="${tabId}" data-open-tab="${tabId}"
data-open-route="${itemRoute}" data-open-route="${itemRoute}"
data-open-title="${escapeHtmlAttribute(item.title)}" data-open-title="${escapeHtmlAttribute(tText(item.title))}"
type="button" type="button"
> >
<strong>${escapeHtml(item.title)}</strong> <strong>${escapeHtml(tText(item.title))}</strong>
<span>${escapeHtml(item.meta || view.label)}</span> <span>${escapeHtml(tText(item.meta || view.label))}</span>
${item.badge ? `<span class="sidebar-badge">${escapeHtml(item.badge)}</span>` : ""} ${item.badge ? `<span class="sidebar-badge">${escapeHtml(tText(item.badge))}</span>` : ""}
</button> </button>
`; `;
} }
@@ -168,7 +187,7 @@ function renderTabs() {
const node = root.querySelector(".tab-bar"); const node = root.querySelector(".tab-bar");
if (tabs.length === 0) { if (tabs.length === 0) {
node.innerHTML = `<div class="tab-bar-empty">Dashboard</div>`; node.innerHTML = `<div class="tab-bar-empty">${escapeHtml(t("Dashboard"))}</div>`;
return; return;
} }
@@ -186,8 +205,8 @@ function renderTab(tab) {
return ` return `
<button class="tab ${active ? "active" : ""} ${tab.is_transient ? "transient" : ""}" data-tab-type="${tab.type}" data-tab-id="${tab.id}" type="button"> <button class="tab ${active ? "active" : ""} ${tab.is_transient ? "transient" : ""}" data-tab-type="${tab.type}" data-tab-id="${tab.id}" type="button">
<span class="tab-icon">${tabIcon(tab.type)}</span> <span class="tab-icon">${tabIcon(tab.type)}</span>
<span class="tab-title">${escapeHtml(meta.title)}</span> <span class="tab-title">${escapeHtml(tText(meta.title))}</span>
<span class="tab-close" data-close-tab="${tab.type}:${tab.id}" role="button" aria-label="Close ${escapeHtmlAttribute(meta.title)}" title="Close tab">×</span> <span class="tab-close" data-close-tab="${tab.type}:${tab.id}" role="button" aria-label="${escapeHtmlAttribute(t("Close %{title}", { title: tText(meta.title) }))}" title="${escapeHtmlAttribute(t("Close tab"))}">×</span>
</button> </button>
`; `;
} }
@@ -210,8 +229,8 @@ function renderEditor() {
.map( .map(
(item) => ` (item) => `
<section class="editor-meta-row"> <section class="editor-meta-row">
<strong data-testid="editor-meta-label">${escapeHtml(item.label)}</strong> <strong data-testid="editor-meta-label">${escapeHtml(tText(item.label))}</strong>
<span>${escapeHtml(item.value)}</span> <span>${escapeHtml(tText(item.value))}</span>
</section> </section>
` `
) )
@@ -230,14 +249,14 @@ function renderEditorBody(route) {
<section class="editor-section"> <section class="editor-section">
<ul class="editor-list compact"> <ul class="editor-list compact">
${dashboard.summary_cards ${dashboard.summary_cards
.map((card) => `<li><strong>${escapeHtml(card.label)}:</strong> ${escapeHtml(card.value)} <span>${escapeHtml(card.detail)}</span></li>`) .map((card) => `<li><strong>${escapeHtml(tText(card.label))}:</strong> ${escapeHtml(card.value)} <span>${escapeHtml(tText(card.detail))}</span></li>`)
.join("")} .join("")}
</ul> </ul>
</section> </section>
<section class="editor-section"> <section class="editor-section">
<h2>Workbench Notes</h2> <h2>${escapeHtml(t("Workbench Notes"))}</h2>
<ul class="editor-list"> <ul class="editor-list">
${dashboard.checklist.map((entry) => `<li>${escapeHtml(entry)}</li>`).join("")} ${dashboard.checklist.map((entry) => `<li>${escapeHtml(tText(entry))}</li>`).join("")}
</ul> </ul>
</section> </section>
`; `;
@@ -250,13 +269,13 @@ function renderEditorBody(route) {
const active = activeItem(); const active = activeItem();
return ` return `
<div class="editor-toolbar"> <div class="editor-toolbar">
<button class="editor-toolbar-button" type="button">Open</button> <button class="editor-toolbar-button" type="button">${escapeHtml(t("Open"))}</button>
<button class="editor-toolbar-button" type="button">Preview</button> <button class="editor-toolbar-button" type="button">${escapeHtml(t("Preview"))}</button>
<button class="editor-toolbar-button" type="button">Metadata</button> <button class="editor-toolbar-button" type="button">${escapeHtml(t("Metadata"))}</button>
</div> </div>
<div class="editor-section"> <div class="editor-section">
<h2>${escapeHtml(active?.title || routeLabel(route))}</h2> <h2>${escapeHtml(tText(active?.title || routeLabel(route)))}</h2>
<p>${escapeHtml(active?.meta || "Desktop workbench content routed through the Elixir shell.")}</p> <p>${escapeHtml(tText(active?.meta || "Desktop workbench content routed through the Elixir shell."))}</p>
</div> </div>
`; `;
} }
@@ -292,7 +311,7 @@ function renderPanelBody() {
return ` return `
<div class="panel-entry"> <div class="panel-entry">
<strong>${escapeHtml(routeLabel(state.session.panel.active_tab))}</strong> <strong>${escapeHtml(routeLabel(state.session.panel.active_tab))}</strong>
<span>The shared lower panel is available for tasks, output, git details, and editor-specific diagnostics.</span> <span>${escapeHtml(t("The shared lower panel is available for tasks, output, git details, and editor-specific diagnostics."))}</span>
</div> </div>
`; `;
} }
@@ -301,8 +320,8 @@ function renderTaskPanelEntries() {
if (!state.taskStatus.tasks.length) { if (!state.taskStatus.tasks.length) {
return ` return `
<div class="panel-entry panel-empty-state"> <div class="panel-entry panel-empty-state">
<strong>Tasks</strong> <strong>${escapeHtml(t("Tasks"))}</strong>
<span>No background tasks running</span> <span>${escapeHtml(t("No background tasks running"))}</span>
</div> </div>
`; `;
} }
@@ -335,8 +354,8 @@ function renderOutputEntries() {
if (!state.outputEntries.length) { if (!state.outputEntries.length) {
return ` return `
<div class="panel-entry panel-empty-state output-list"> <div class="panel-entry panel-empty-state output-list">
<strong>Output</strong> <strong>${escapeHtml(t("Output"))}</strong>
<span>No shell output yet</span> <span>${escapeHtml(t("No shell output yet"))}</span>
</div> </div>
`; `;
} }
@@ -362,8 +381,8 @@ function renderGitLogEntries() {
return ` return `
<div class="git-log-list"> <div class="git-log-list">
<div class="panel-entry"> <div class="panel-entry">
<strong>Git Log</strong> <strong>${escapeHtml(t("Git Log"))}</strong>
<span>Working tree integration is not wired yet in the shell, but the tab is selectable and ready for command output.</span> <span>${escapeHtml(t("Working tree integration is not wired yet in the shell, but the tab is selectable and ready for command output."))}</span>
</div> </div>
</div> </div>
`; `;
@@ -372,15 +391,15 @@ function renderGitLogEntries() {
function renderAssistant() { function renderAssistant() {
root.querySelector(".assistant-sidebar").innerHTML = ` root.querySelector(".assistant-sidebar").innerHTML = `
<div class="assistant-header"> <div class="assistant-header">
<strong>Assistant</strong> <strong>${escapeHtml(t("Assistant"))}</strong>
</div> </div>
<div class="assistant-content"> <div class="assistant-content">
${bootstrap.content.assistant_cards ${bootstrap.content.assistant_cards
.map( .map(
(card) => ` (card) => `
<section class="assistant-card"> <section class="assistant-card">
<strong>${escapeHtml(card.label)}</strong> <strong>${escapeHtml(tText(card.label))}</strong>
<span>${escapeHtml(card.text)}</span> <span>${escapeHtml(tText(card.text))}</span>
</section> </section>
` `
) )
@@ -392,23 +411,25 @@ function renderAssistant() {
function renderStatusBar() { function renderStatusBar() {
const status = state.status; const status = state.status;
const taskOverflow = state.taskStatus.running_task_overflow; const taskOverflow = state.taskStatus.running_task_overflow;
const taskMessage = state.taskStatus.running_task_message || "Idle"; const taskMessage = state.taskStatus.running_task_message || t("Idle");
const postCount = status.right.post_count_value ?? status.right.post_count;
const mediaCount = status.right.media_count_value ?? status.right.media_count;
root.querySelector(".status-bar").innerHTML = ` root.querySelector(".status-bar").innerHTML = `
<div class="status-bar-left"> <div class="status-bar-left">
${renderProjectSelector()} ${renderProjectSelector()}
<button class="status-bar-item status-bar-task-button" data-command="open-tasks-panel" type="button"> <button class="status-bar-item status-bar-task-button" data-command="open-tasks-panel" type="button">
<span>${escapeHtml(taskMessage)}</span> <span>${escapeHtml(tText(taskMessage))}</span>
${taskOverflow > 0 ? `<span class="status-bar-count">+${taskOverflow}</span>` : ""} ${taskOverflow > 0 ? `<span class="status-bar-count">+${taskOverflow}</span>` : ""}
</button> </button>
</div> </div>
<div class="status-bar-right"> <div class="status-bar-right">
<span class="status-bar-item">${escapeHtml(status.right.post_count)}</span> <span class="status-bar-item">${escapeHtml(typeof postCount === "number" ? t("%{count} posts", { count: postCount }) : tText(postCount))}</span>
<span class="status-bar-item">${escapeHtml(status.right.media_count)}</span> <span class="status-bar-item">${escapeHtml(typeof mediaCount === "number" ? t("%{count} media", { count: mediaCount }) : tText(mediaCount))}</span>
<span class="status-bar-item theme-badge">${escapeHtml(status.right.theme_badge)}</span> <span class="status-bar-item theme-badge">${escapeHtml(status.right.theme_badge)}</span>
<button class="status-bar-item offline-badge${status.right.offline_mode ? " active" : ""}" data-command="toggle-offline-mode" type="button" title="Toggle offline mode">✈</button> <button class="status-bar-item offline-badge${status.right.offline_mode ? " active" : ""}" data-command="toggle-offline-mode" type="button" title="${escapeHtmlAttribute(t("Toggle offline mode"))}">✈</button>
<label class="status-bar-item language-badge"> <label class="status-bar-item language-badge">
<span>UI</span> <span>${escapeHtml(t("UI"))}</span>
<select class="status-bar-language-select" data-command="set-ui-language">${renderLanguageOptions()}</select> <select class="status-bar-language-select" data-command="set-ui-language">${renderLanguageOptions()}</select>
</label> </label>
<span class="status-bar-item brand">${escapeHtml(status.right.brand)}</span> <span class="status-bar-item brand">${escapeHtml(status.right.brand)}</span>
@@ -657,7 +678,7 @@ async function fetchProjects() {
} }
async function createProject() { async function createProject() {
const name = window.prompt("New project name", "New Project"); const name = window.prompt(t("New project name"), t("New Project"));
if (!name || !name.trim()) { if (!name || !name.trim()) {
return; return;
} }
@@ -677,17 +698,17 @@ async function createProject() {
const payload = await response.json(); const payload = await response.json();
if (!response.ok || payload.status !== "ok") { if (!response.ok || payload.status !== "ok") {
appendOutputEntry("Create Project", payload.error?.message || `Command failed with HTTP ${response.status}`); appendOutputEntry(t("Create Project"), payload.error?.message || t("Command failed with HTTP %{status}", { status: response.status }));
setPanelTab("output"); setPanelTab("output");
render(); render();
return; return;
} }
await fetchProjects(); await fetchProjects();
appendOutputEntry("Create Project", `Activated ${payload.project.name}`); appendOutputEntry(t("Create Project"), t("Activated %{name}", { name: payload.project.name }));
render(); render();
} catch (error) { } catch (error) {
appendOutputEntry("Create Project", error?.message || String(error)); appendOutputEntry(t("Create Project"), error?.message || String(error));
setPanelTab("output"); setPanelTab("output");
render(); render();
} }
@@ -714,17 +735,17 @@ async function selectProject(projectId) {
const payload = await response.json(); const payload = await response.json();
if (!response.ok || payload.status !== "ok") { if (!response.ok || payload.status !== "ok") {
appendOutputEntry("Select Project", payload.error?.message || `Command failed with HTTP ${response.status}`); appendOutputEntry(t("Select Project"), payload.error?.message || t("Command failed with HTTP %{status}", { status: response.status }));
setPanelTab("output"); setPanelTab("output");
render(); render();
return; return;
} }
await fetchProjects(); await fetchProjects();
appendOutputEntry("Select Project", `Activated ${payload.project.name}`); appendOutputEntry(t("Select Project"), t("Activated %{name}", { name: payload.project.name }));
render(); render();
} catch (error) { } catch (error) {
appendOutputEntry("Select Project", error?.message || String(error)); appendOutputEntry(t("Select Project"), error?.message || String(error));
setPanelTab("output"); setPanelTab("output");
render(); render();
} }
@@ -792,11 +813,11 @@ function executeLocalShellCommand(action) {
openSingletonTab("api_documentation"); openSingletonTab("api_documentation");
return true; return true;
case "regenerate_calendar": case "regenerate_calendar":
appendOutputEntry("Regenerate Calendar", "Calendar regeneration is not wired yet, but the base shell now surfaces the command and keeps the Output tab selectable."); appendOutputEntry(t("Regenerate Calendar"), t("Calendar regeneration is not wired yet, but the base shell now surfaces the command and keeps the Output tab selectable."));
setPanelTab("output"); setPanelTab("output");
return true; return true;
case "fill_missing_translations": case "fill_missing_translations":
appendOutputEntry("Fill Missing Translations", "Translation fill is not wired yet, but the command is now routed into Output instead of being ignored."); appendOutputEntry(t("Fill Missing Translations"), t("Translation fill is not wired yet, but the command is now routed into Output instead of being ignored."));
setPanelTab("output"); setPanelTab("output");
return true; return true;
case "toggle_offline_mode": case "toggle_offline_mode":
@@ -819,7 +840,7 @@ async function executeBackendShellCommand(action) {
}); });
if (!response.ok) { if (!response.ok) {
appendOutputEntry(routeLabel(action), `Command failed with HTTP ${response.status}`); appendOutputEntry(routeLabel(action), t("Command failed with HTTP %{status}", { status: response.status }));
setPanelTab("output"); setPanelTab("output");
render(); render();
return; return;
@@ -850,7 +871,7 @@ function applyShellCommandResult(result) {
void fetchTaskStatus(); void fetchTaskStatus();
break; break;
case "open_url": case "open_url":
appendOutputEntry(result.title, result.url || result.message || "Opened URL"); appendOutputEntry(tText(result.title), tText(result.url || result.message || "Opened URL"));
setPanelTab("output"); setPanelTab("output");
if (result.url) { if (result.url) {
@@ -867,11 +888,11 @@ function applyShellCommandResult(result) {
}); });
return; return;
case "output": case "output":
appendOutputEntry(result.title, result.message, result.details); appendOutputEntry(tText(result.title), tText(result.message), result.details);
setPanelTab(result.panel_tab || "output"); setPanelTab(result.panel_tab || "output");
break; break;
default: default:
appendOutputEntry(routeLabel(result.action || "output"), result.message || "Command completed"); appendOutputEntry(routeLabel(result.action || "output"), tText(result.message || "Command completed"));
setPanelTab("output"); setPanelTab("output");
break; break;
} }
@@ -880,7 +901,7 @@ function applyShellCommandResult(result) {
} }
function applyShellCommandError(action, error) { function applyShellCommandError(action, error) {
appendOutputEntry(routeLabel(action), error?.message || "Command failed"); appendOutputEntry(routeLabel(action), error?.message || t("Command failed"));
setPanelTab("output"); setPanelTab("output");
render(); render();
} }
@@ -996,7 +1017,7 @@ function tabMetadata(tab) {
const item = activeItem(); const item = activeItem();
if (item && tab.id === tabIdForItem(item, item.route)) { if (item && tab.id === tabIdForItem(item, item.route)) {
return { title: item.title }; return { title: tText(item.title) };
} }
return { title: routeLabel(tab.type) }; return { title: routeLabel(tab.type) };
@@ -1026,53 +1047,53 @@ function currentEditorMeta() {
function editorTitle() { function editorTitle() {
const meta = currentTabMeta(); const meta = currentTabMeta();
if (meta?.title) { if (meta?.title) {
return meta.title; return tText(meta.title);
} }
const item = activeItem(); const item = activeItem();
return item?.title || bootstrap.content.dashboard.title; return tText(item?.title || bootstrap.content.dashboard.title);
} }
function editorSubtitle(route) { function editorSubtitle(route) {
const meta = currentTabMeta(); const meta = currentTabMeta();
if (meta?.subtitle) { if (meta?.subtitle) {
return meta.subtitle; return tText(meta.subtitle);
} }
if (route === "dashboard") { if (route === "dashboard") {
return bootstrap.content.dashboard.subtitle; return tText(bootstrap.content.dashboard.subtitle);
} }
const item = activeItem(); const item = activeItem();
return item?.meta || `${routeLabel(route)} content loaded through the desktop shell.`; return tText(item?.meta || "Desktop workbench content routed through the Elixir shell.");
} }
function routeLabel(route) { function routeLabel(route) {
if (!route) { if (!route) {
return "Dashboard"; return t("Dashboard");
} }
if (route === "output") { if (route === "output") {
return "Output"; return t("Output");
} }
if (route === "git_log") { if (route === "git_log") {
return "Git Log"; return t("Git Log");
} }
if (route === "open_in_browser") { if (route === "open_in_browser") {
return "Open in Browser"; return t("Open in Browser");
} }
if (route === "open_data_folder") { if (route === "open_data_folder") {
return "Open Data Folder"; return t("Open Data Folder");
} }
if (route === "upload_site") { if (route === "upload_site") {
return "Upload Site"; return t("Upload Site");
} }
return ( return tText(
bootstrap.registry.editor_routes.find((item) => item.id === route)?.title || bootstrap.registry.editor_routes.find((item) => item.id === route)?.title ||
sidebarViews().find((item) => item.id === route)?.label || sidebarViews().find((item) => item.id === route)?.label ||
titleCase(route) titleCase(route)
@@ -1085,16 +1106,16 @@ function renderCommandPayload(route, payload) {
return ` return `
<section class="editor-section"> <section class="editor-section">
<ul class="editor-list compact"> <ul class="editor-list compact">
<li><strong>Diffs:</strong> ${escapeHtml(String(payload.summary?.diff_count || 0))}</li> <li><strong>${escapeHtml(t("Diffs"))}:</strong> ${escapeHtml(String(payload.summary?.diff_count || 0))}</li>
<li><strong>Orphans:</strong> ${escapeHtml(String(payload.summary?.orphan_count || 0))}</li> <li><strong>${escapeHtml(t("Orphans"))}:</strong> ${escapeHtml(String(payload.summary?.orphan_count || 0))}</li>
</ul> </ul>
</section> </section>
<section class="editor-section"> <section class="editor-section">
<h2>Diff Reports</h2> <h2>${escapeHtml(t("Diff Reports"))}</h2>
${renderKeyedEntries(payload.diff_reports, ["entity_type", "entity_id", "differences"])} ${renderKeyedEntries(payload.diff_reports, ["entity_type", "entity_id", "differences"])}
</section> </section>
<section class="editor-section"> <section class="editor-section">
<h2>Orphan Reports</h2> <h2>${escapeHtml(t("Orphan Reports"))}</h2>
${renderKeyedEntries(payload.orphan_reports, ["entity_type", "path"])} ${renderKeyedEntries(payload.orphan_reports, ["entity_type", "path"])}
</section> </section>
`; `;
@@ -1102,51 +1123,51 @@ function renderCommandPayload(route, payload) {
return ` return `
<section class="editor-section"> <section class="editor-section">
<ul class="editor-list compact"> <ul class="editor-list compact">
<li><strong>Missing:</strong> ${escapeHtml(String(payload.summary?.missing_count || 0))}</li> <li><strong>${escapeHtml(t("Missing"))}:</strong> ${escapeHtml(String(payload.summary?.missing_count || 0))}</li>
<li><strong>Extra:</strong> ${escapeHtml(String(payload.summary?.extra_count || 0))}</li> <li><strong>${escapeHtml(t("Extra"))}:</strong> ${escapeHtml(String(payload.summary?.extra_count || 0))}</li>
<li><strong>Stale:</strong> ${escapeHtml(String(payload.summary?.stale_count || 0))}</li> <li><strong>${escapeHtml(t("Stale"))}:</strong> ${escapeHtml(String(payload.summary?.stale_count || 0))}</li>
</ul> </ul>
</section> </section>
<section class="editor-section"> <section class="editor-section">
<h2>Missing Pages</h2> <h2>${escapeHtml(t("Missing Pages"))}</h2>
${renderStringList(payload.missing_pages, "No missing pages")} ${renderStringList(payload.missing_pages, t("No missing pages"))}
</section> </section>
<section class="editor-section"> <section class="editor-section">
<h2>Extra Pages</h2> <h2>${escapeHtml(t("Extra Pages"))}</h2>
${renderStringList(payload.extra_pages, "No extra pages")} ${renderStringList(payload.extra_pages, t("No extra pages"))}
</section> </section>
<section class="editor-section"> <section class="editor-section">
<h2>Stale Pages</h2> <h2>${escapeHtml(t("Stale Pages"))}</h2>
${renderStringList(payload.stale_pages, "No stale pages")} ${renderStringList(payload.stale_pages, t("No stale pages"))}
</section> </section>
`; `;
case "translation_validation": case "translation_validation":
return ` return `
<section class="editor-section"> <section class="editor-section">
<ul class="editor-list compact"> <ul class="editor-list compact">
<li><strong>Missing:</strong> ${escapeHtml(String(payload.summary?.missing_count || 0))}</li> <li><strong>${escapeHtml(t("Missing"))}:</strong> ${escapeHtml(String(payload.summary?.missing_count || 0))}</li>
<li><strong>Orphan Files:</strong> ${escapeHtml(String(payload.summary?.orphan_count || 0))}</li> <li><strong>${escapeHtml(t("Orphan Files"))}:</strong> ${escapeHtml(String(payload.summary?.orphan_count || 0))}</li>
<li><strong>Do Not Translate:</strong> ${escapeHtml(String(payload.summary?.do_not_translate_count || 0))}</li> <li><strong>${escapeHtml(t("Do Not Translate"))}:</strong> ${escapeHtml(String(payload.summary?.do_not_translate_count || 0))}</li>
</ul> </ul>
</section> </section>
<section class="editor-section"> <section class="editor-section">
<h2>Missing Translations</h2> <h2>${escapeHtml(t("Missing Translations"))}</h2>
${renderKeyedEntries(payload.missing, ["post_id", "language"])} ${renderKeyedEntries(payload.missing, ["post_id", "language"])}
</section> </section>
<section class="editor-section"> <section class="editor-section">
<h2>Orphan Files</h2> <h2>${escapeHtml(t("Orphan Files"))}</h2>
${renderStringList(payload.orphan_files, "No orphan translation files")} ${renderStringList(payload.orphan_files, t("No orphan translation files"))}
</section> </section>
`; `;
case "find_duplicates": case "find_duplicates":
return ` return `
<section class="editor-section"> <section class="editor-section">
<ul class="editor-list compact"> <ul class="editor-list compact">
<li><strong>Pairs:</strong> ${escapeHtml(String(payload.summary?.pair_count || 0))}</li> <li><strong>${escapeHtml(t("Pairs"))}:</strong> ${escapeHtml(String(payload.summary?.pair_count || 0))}</li>
</ul> </ul>
</section> </section>
<section class="editor-section"> <section class="editor-section">
<h2>Duplicate Candidates</h2> <h2>${escapeHtml(t("Duplicate Candidates"))}</h2>
${renderKeyedEntries(payload.pairs, ["title_a", "title_b", "score"])} ${renderKeyedEntries(payload.pairs, ["title_a", "title_b", "score"])}
</section> </section>
`; `;
@@ -1169,7 +1190,7 @@ function renderStringList(items, emptyMessage) {
function renderKeyedEntries(items, keys) { function renderKeyedEntries(items, keys) {
if (!items || !items.length) { if (!items || !items.length) {
return `<p>No items</p>`; return `<p>${escapeHtml(t("No items"))}</p>`;
} }
return ` return `
@@ -1330,20 +1351,20 @@ function normalizeProjects(projectsPayload) {
} }
function panelTabs() { function panelTabs() {
return ["tasks", state.session.panel.active_tab, "output", "git_log"].filter(uniqueValue); return ["tasks", "output", "git_log", state.session.panel.active_tab].filter(uniqueValue);
} }
function renderPanelTab(tab) { function renderPanelTab(tab) {
if (tab === "tasks") { if (tab === "tasks") {
return `<button class="panel-tab ${state.session.panel.active_tab === "tasks" ? "active" : ""}" data-panel-tab="tasks" type="button">Tasks</button>`; return `<button class="panel-tab ${state.session.panel.active_tab === "tasks" ? "active" : ""}" data-panel-tab="tasks" type="button">${escapeHtml(t("Tasks"))}</button>`;
} }
if (tab === "output") { if (tab === "output") {
return `<button class="panel-tab ${state.session.panel.active_tab === "output" ? "active" : ""}" data-panel-tab="output" type="button">Output</button>`; return `<button class="panel-tab ${state.session.panel.active_tab === "output" ? "active" : ""}" data-panel-tab="output" type="button">${escapeHtml(t("Output"))}</button>`;
} }
if (tab === "git_log") { if (tab === "git_log") {
return `<button class="panel-tab ${state.session.panel.active_tab === "git_log" ? "active" : ""}" data-panel-tab="git_log" type="button">Git Log</button>`; return `<button class="panel-tab ${state.session.panel.active_tab === "git_log" ? "active" : ""}" data-panel-tab="git_log" type="button">${escapeHtml(t("Git Log"))}</button>`;
} }
return `<button class="panel-tab ${state.session.panel.active_tab === tab ? "active" : ""}" data-panel-tab="${tab}" type="button">${escapeHtml(routeLabel(tab))}</button>`; return `<button class="panel-tab ${state.session.panel.active_tab === tab ? "active" : ""}" data-panel-tab="${tab}" type="button">${escapeHtml(routeLabel(tab))}</button>`;
@@ -1363,7 +1384,7 @@ function renderProjectSelector() {
return ` return `
<div class="project-selector${state.projectMenuOpen ? " is-open" : ""}"> <div class="project-selector${state.projectMenuOpen ? " is-open" : ""}">
<button class="project-selector-trigger" data-project-menu-trigger type="button" title="Switch project"> <button class="project-selector-trigger" data-project-menu-trigger type="button" title="${escapeHtmlAttribute(t("Switch project"))}">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" class="project-icon"> <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" class="project-icon">
<path d="M14.5 3H7.71l-.85-.85A.5.5 0 0 0 6.5 2h-5a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5v-10a.5.5 0 0 0-.5-.5zm-13 1h5.29l.85.85c.1.1.23.15.36.15h6.5v9h-13V4z"></path> <path d="M14.5 3H7.71l-.85-.85A.5.5 0 0 0 6.5 2h-5a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5v-10a.5.5 0 0 0-.5-.5zm-13 1h5.29l.85.85c.1.1.23.15.36.15h6.5v9h-13V4z"></path>
</svg> </svg>
@@ -1381,7 +1402,7 @@ function renderProjectDropdown() {
return ` return `
<div class="project-dropdown"> <div class="project-dropdown">
<div class="project-dropdown-header"> <div class="project-dropdown-header">
<span>Projects</span> <span>${escapeHtml(t("Projects"))}</span>
</div> </div>
<div class="project-list"> <div class="project-list">
${state.projects.projects.map((project) => renderProjectItem(project)).join("")} ${state.projects.projects.map((project) => renderProjectItem(project)).join("")}
@@ -1391,7 +1412,7 @@ function renderProjectDropdown() {
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"> <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"></path> <path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"></path>
</svg> </svg>
New Project ${escapeHtml(t("New Project"))}
</button> </button>
</div> </div>
</div> </div>
@@ -1459,11 +1480,11 @@ function readStoredUiLanguage(fallback) {
function statusLabel(status) { function statusLabel(status) {
switch (status) { switch (status) {
case "running": case "running":
return "Running"; return t("Running");
case "pending": case "pending":
return "Queued"; return t("Queued");
default: default:
return titleCase(status || "task"); return tText(titleCase(status || "task"));
} }
} }

View File

@@ -115,6 +115,27 @@ defmodule BDS.UI.ShellTest do
assert html =~ ~s("name":"My Blog") assert html =~ ~s("name":"My Blog")
end end
test "shell page localizes bootstrap content for german ui locale" do
previous_lang = System.get_env("LANG")
previous_lc_all = System.get_env("LC_ALL")
on_exit(fn ->
restore_env("LANG", previous_lang)
restore_env("LC_ALL", previous_lc_all)
end)
System.put_env("LANG", "de_DE.UTF-8")
System.delete_env("LC_ALL")
html = ShellPage.render()
assert html =~ ~s("ui_language":"de")
assert html =~ ~s("catalogs")
assert html =~ ~s("File":"Datei")
assert html =~ ~s("Dashboard":"Instrumententafel")
assert html =~ ~s("Assistant":"Assistent")
end
test "static shell bundle exists for direct browser inspection" do 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/index.html")
assert File.exists?("/Users/gb/Projects/bDS2/priv/ui/app.css") assert File.exists?("/Users/gb/Projects/bDS2/priv/ui/app.css")
@@ -185,6 +206,12 @@ defmodule BDS.UI.ShellTest do
assert js =~ "data-panel-tab=\"git_log\"" assert js =~ "data-panel-tab=\"git_log\""
end end
test "static shell bundle keeps bottom panel tabs in a stable order" do
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
assert js =~ "return [\"tasks\", \"output\", \"git_log\", state.session.panel.active_tab].filter(uniqueValue);"
end
test "static shell bundle renders a left-side project field with selection and create affordances" do test "static shell bundle renders a left-side project field with selection and create affordances" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css") css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js") js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
@@ -203,6 +230,16 @@ defmodule BDS.UI.ShellTest do
assert css =~ ".create-project-btn" assert css =~ ".create-project-btn"
end end
test "static shell bundle uses translation catalogs for visible shell chrome" do
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
assert js =~ "translationsForLanguage"
assert js =~ "function t("
assert js =~ "New Project"
assert js =~ "No background tasks running"
assert js =~ "Idle"
end
test "static shell bundle binds base shell hotkeys and menu actions to existing shell functionality" do test "static shell bundle binds base shell hotkeys and menu actions to existing shell functionality" do
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js") js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
@@ -222,4 +259,7 @@ defmodule BDS.UI.ShellTest do
assert js =~ "[data-close-tab]" assert js =~ "[data-close-tab]"
assert js =~ "language.flag" assert js =~ "language.flag"
end end
defp restore_env(key, nil), do: System.delete_env(key)
defp restore_env(key, value), do: System.put_env(key, value)
end end