fix(i18n): fix DOM timing issue in i18n.js

- Wait for document.body before attaching event listeners
- Prevents TypeError when script loads before body exists
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2026-01-10 10:54:05 -03:00
parent 9a4c8bf6a6
commit 955568c8e4
14 changed files with 3020 additions and 660 deletions

232
TODO.md
View file

@ -1,232 +0,0 @@
# SMB Suite Implementation TODO
## Overview
Complete sovereign SMB suite with CRM, Billing, Products, Tickets, and Forms.
Following Microsoft Dynamics nomenclature (simplified for SMB).
---
## ✅ COMPLETED
### Phase 1: Create HTML/CSS for New Apps ✅
#### 1.1 CRM (`/suite/crm/`) ✅
- [x] `crm.html` - Pipeline view (Kanban style)
- [x] `crm.css` - Styling
- [x] Entities (Dynamics nomenclature):
- **Lead** - Unqualified prospect
- **Opportunity** - Qualified, in sales process
- **Account** - Company (converted customer)
- **Contact** - Person at Account
- **Activity** - Linked tasks/calls/emails
#### 1.2 Billing (`/suite/billing/`) ✅
- [x] `billing.html` - Invoice list + dashboard
- [x] `billing.css` - Styling
- [x] Entities:
- **Invoice** - Bill to customer
- **Payment** - Payment received
- **Quote** - Price quotation → converts to Invoice
#### 1.3 Products (`/suite/products/`) ✅
- [x] `products.html` - Product/Service catalog
- [x] `products.css` - Styling
- [x] Entities:
- **Product** - Physical/digital product
- **Service** - Service offering
- **PriceList** - Pricing tiers
#### 1.4 Tickets (`/suite/tickets/`) ✅
- [x] `tickets.html` - AI-assisted support tickets
- [x] `tickets.css` - Styling
- [x] Entities:
- **Case** - Support ticket (Dynamics term)
- **Resolution** - AI-suggested solutions
#### 1.5 Forms (`/suite/forms/`) ✅
- [x] `forms.html` - Redirect to Tasks with AI prompt
- [x] Behavior: "Create a form for me about [topic]"
### Phase 2: Menu Integration (`/suite/index.html`) ✅
- [x] Add CRM to dropdown menu
- [x] Add Billing to dropdown menu
- [x] Add Products to dropdown menu
- [x] Add Tickets to dropdown menu
- [x] Add Forms to dropdown menu
- [x] Update header tabs (add CRM)
- [x] Update CSS breakpoints (`/suite/css/app.css`)
### Phase 3: i18n Updates ✅
**NOTE:** Translations are stored in `.ftl` files in `botlib/locales/` - NOT in JS files.
#### English (`botlib/locales/en/ui.ftl`) ✅
- [x] nav-crm, nav-billing, nav-products, nav-tickets, nav-forms
- [x] CRM: lead, opportunity, account, contact, pipeline, qualify, convert, won, lost
- [x] Billing: invoice, payment, quote, due-date, overdue, paid, pending
- [x] Products: product, service, price, sku, category, unit
- [x] Tickets: case, priority, status, assigned, resolved, escalate
#### Portuguese (`botlib/locales/pt-BR/ui.ftl`) ✅
- [x] nav-crm: "CRM"
- [x] nav-billing: "Faturamento"
- [x] nav-products: "Produtos"
- [x] nav-tickets: "Chamados"
- [x] nav-forms: "Formulários"
- [x] All entity labels in Portuguese
#### Spanish (`botlib/locales/es/ui.ftl`) ✅
- [x] All navigation and entity labels in Spanish
---
## 📋 TODO
### Phase 4: Chat @ Mentions
- [ ] Add @ autocomplete in chat input
- [ ] Entity types to reference:
- @lead:name
- @opportunity:name
- @account:name
- @contact:name
- @invoice:number
- @case:number
- @product:name
- [ ] Show entity card on hover
- [ ] Navigate to entity on click
### Phase 5: Reports (in Analytics/Dashboards)
#### CRM Reports
- [ ] Sales Pipeline (funnel)
- [ ] Lead Conversion Rate
- [ ] Opportunities by Stage
- [ ] Won/Lost Analysis
- [ ] Sales Forecast
#### Billing Reports
- [ ] Revenue Summary
- [ ] Aging Report (overdue invoices)
- [ ] Payment History
- [ ] Monthly Revenue
#### Support Reports
- [ ] Open Cases by Priority
- [ ] Resolution Time (avg)
- [ ] Cases by Category
- [ ] AI Resolution Rate
### Phase 6: BotBook Documentation
- [ ] Add CRM chapter
- [ ] Add Billing chapter
- [ ] Add Products chapter
- [ ] Add Tickets chapter
- [ ] Document @ mentions
- [ ] Update SUMMARY.md
---
## File Structure
### i18n Files (Fluent format .ftl):
```
botlib/locales/
├── en/
│ └── ui.ftl # English translations
├── pt-BR/
│ └── ui.ftl # Portuguese translations
└── es/
└── ui.ftl # Spanish translations
```
### Suite Files:
```
botui/ui/suite/
├── crm/
│ ├── crm.html
│ └── crm.css
├── billing/
│ ├── billing.html
│ └── billing.css
├── products/
│ ├── products.html
│ └── products.css
├── tickets/
│ ├── tickets.html
│ └── tickets.css
├── forms/
│ └── forms.html
├── index.html # Menu items + HTMX routes
└── css/
└── app.css # Breakpoints for tabs
```
### BotBook Files (TODO):
```
botbook/src/
├── SUMMARY.md # Add new chapters
├── XX-crm/ # CRM documentation
├── XX-billing/ # Billing documentation
├── XX-products/ # Products documentation
└── XX-tickets/ # Tickets documentation
```
---
## Entity Relationships (Dynamics Style)
```
Lead ──(qualify)──► Opportunity ──(convert)──► Account + Contact
Quote ──(accept)──► Invoice ──(pay)──► Payment
└── Product/Service (line items)
Account ◄──► Contact (1:N)
Account ◄──► Case/Ticket (1:N)
Account ◄──► Invoice (1:N)
Account ◄──► Opportunity (1:N)
```
---
## HTMX Patterns to Use
### List with selection
```html
<div hx-get="/api/crm/leads" hx-trigger="load" hx-target="#lead-list">
Loading...
</div>
```
### Pipeline drag-drop
```html
<div class="pipeline-column"
hx-post="/api/crm/opportunity/{id}/stage"
hx-trigger="drop"
hx-vals='{"stage": "qualified"}'>
```
### @ Mention autocomplete
```html
<input type="text"
hx-get="/api/search/entities?q="
hx-trigger="keyup changed delay:300ms"
hx-target="#mention-dropdown">
```
---
## Notes
- **i18n Location**: All translations in `botlib/locales/{locale}/ui.ftl` files (Fluent format)
- **Dynamics Nomenclature**: Lead, Opportunity, Account, Contact, Case, Quote, Invoice
- **SMB Focus**: Simple, not enterprise complexity
- **AI-First**: Tickets use AI suggestions, Forms use AI generation
- **HTMX**: All interactions via HTMX
- **Sovereign**: No external dependencies, all data local
- **@ Mentions**: Reference any entity in chat with @type:name

View file

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<!-- Shield shape - security/compliance -->
<path d="M12 2 L20 6 L20 12 C20 16.418 16.418 20 12 22 C7.582 20 4 16.418 4 12 L4 6 Z"/>
<!-- Checkmark inside shield -->
<polyline points="8 12 11 15 16 9"/>
<!-- Document lines - representing compliance docs -->
<line x1="9" y1="18" x2="15" y2="18" opacity="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 491 B

View file

@ -312,7 +312,7 @@
.app-icon.attendant { .app-icon.attendant {
background: linear-gradient(135deg, #22c55e, #16a34a); background: linear-gradient(135deg, #22c55e, #16a34a);
} }
.app-icon.compliance { .app-icon.security {
background: linear-gradient(135deg, #3b82f6, #1d4ed8); background: linear-gradient(135deg, #3b82f6, #1d4ed8);
} }
.app-icon.monitoring { .app-icon.monitoring {

View file

@ -904,7 +904,7 @@
</button> </button>
<button class="toolbar-btn" id="btn-open" <button class="toolbar-btn" id="btn-open"
hx-get="/api/v1/designer/files" hx-get="/api/designer/files"
hx-target="#file-list-content" hx-target="#file-list-content"
hx-trigger="click" hx-trigger="click"
onclick="showModal('open-modal')" onclick="showModal('open-modal')"
@ -916,7 +916,7 @@
</button> </button>
<button class="toolbar-btn primary" id="btn-save" <button class="toolbar-btn primary" id="btn-save"
hx-post="/api/v1/designer/save" hx-post="/api/designer/save"
hx-include="#designer-data" hx-include="#designer-data"
hx-indicator="#save-spinner" hx-indicator="#save-spinner"
title="Save (Ctrl+S)"> title="Save (Ctrl+S)">
@ -948,7 +948,7 @@
<div class="toolbar-separator"></div> <div class="toolbar-separator"></div>
<button class="toolbar-btn" id="btn-run" <button class="toolbar-btn" id="btn-run"
hx-post="/api/v1/designer/validate" hx-post="/api/designer/validate"
hx-include="#designer-data" hx-include="#designer-data"
title="Validate Dialog"> title="Validate Dialog">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@ -958,7 +958,7 @@
</button> </button>
<button class="toolbar-btn" id="btn-export" <button class="toolbar-btn" id="btn-export"
hx-get="/api/v1/designer/export" hx-get="/api/designer/export"
hx-include="#designer-data" hx-include="#designer-data"
title="Export as .bas"> title="Export as .bas">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@ -1374,7 +1374,7 @@
<div class="modal-footer"> <div class="modal-footer">
<button class="toolbar-btn" onclick="hideModal('open-modal')">Cancel</button> <button class="toolbar-btn" onclick="hideModal('open-modal')">Cancel</button>
<button class="toolbar-btn primary" id="btn-open-selected" <button class="toolbar-btn primary" id="btn-open-selected"
hx-get="/api/v1/designer/load" hx-get="/api/designer/load"
hx-include="#selected-file" hx-include="#selected-file"
hx-target="#canvas-inner" hx-target="#canvas-inner"
hx-swap="innerHTML" hx-swap="innerHTML"
@ -2372,7 +2372,7 @@
if (state.driveSource) { if (state.driveSource) {
saveToDrive(); saveToDrive();
} else { } else {
htmx.ajax('POST', '/api/v1/designer/save', { htmx.ajax('POST', '/api/designer/save', {
source: document.getElementById('designer-data'), source: document.getElementById('designer-data'),
target: '#status-message' target: '#status-message'
}); });
@ -2594,7 +2594,7 @@
}; };
try { try {
const response = await fetch('/api/v1/designer/magic', { const response = await fetch('/api/designer/magic', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dialogData) body: JSON.stringify(dialogData)

View file

@ -807,7 +807,7 @@ function saveDesign() {
// Trigger HTMX save if available // Trigger HTMX save if available
if (typeof htmx !== 'undefined') { if (typeof htmx !== 'undefined') {
htmx.ajax('POST', '/api/v1/designer/save', { htmx.ajax('POST', '/api/designer/save', {
source: document.getElementById('designer-data'), source: document.getElementById('designer-data'),
target: '#status-message' target: '#status-message'
}); });

View file

@ -359,7 +359,7 @@
<span <span
class="editor-title-text" class="editor-title-text"
id="editor-filename" id="editor-filename"
hx-get="/api/v1/editor/filename" hx-get="/api/editor/filename"
hx-trigger="load" hx-trigger="load"
hx-swap="innerHTML"></div> hx-swap="innerHTML"></div>
Untitled Untitled
@ -367,7 +367,7 @@
<div <div
class="editor-path" class="editor-path"
id="editor-filepath" id="editor-filepath"
hx-get="/api/v1/editor/filepath" hx-get="/api/editor/filepath"
hx-trigger="load" hx-trigger="load"
hx-swap="innerHTML"> hx-swap="innerHTML">
</div> </div>
@ -395,7 +395,7 @@
<div class="toolbar-group"> <div class="toolbar-group">
<button <button
class="btn btn-primary btn-small" class="btn btn-primary btn-small"
hx-post="/api/v1/editor/save" hx-post="/api/editor/save"
hx-include="#text-editor" hx-include="#text-editor"
hx-indicator="#save-spinner" hx-indicator="#save-spinner"
hx-swap="none" hx-swap="none"
@ -405,7 +405,7 @@
</button> </button>
<button <button
class="btn btn-small" class="btn btn-small"
hx-get="/api/v1/editor/save-as" hx-get="/api/editor/save-as"
hx-target="#save-dialog" hx-target="#save-dialog"
hx-swap="innerHTML"> hx-swap="innerHTML">
Save As Save As
@ -415,14 +415,14 @@
<div class="toolbar-group"> <div class="toolbar-group">
<button <button
class="btn btn-small" class="btn btn-small"
hx-post="/api/v1/editor/undo" hx-post="/api/editor/undo"
hx-target="#editor-content" hx-target="#editor-content"
hx-swap="innerHTML"> hx-swap="innerHTML">
↩️ Undo ↩️ Undo
</button> </button>
<button <button
class="btn btn-small" class="btn btn-small"
hx-post="/api/v1/editor/redo" hx-post="/api/editor/redo"
hx-target="#editor-content" hx-target="#editor-content"
hx-swap="innerHTML"> hx-swap="innerHTML">
↪️ Redo ↪️ Redo
@ -432,7 +432,7 @@
<div class="toolbar-group" id="text-tools"> <div class="toolbar-group" id="text-tools">
<button <button
class="btn btn-small" class="btn btn-small"
hx-post="/api/v1/editor/format" hx-post="/api/editor/format"
hx-include="#text-editor" hx-include="#text-editor"
hx-target="#text-editor" hx-target="#text-editor"
hx-swap="innerHTML"> hx-swap="innerHTML">
@ -449,14 +449,14 @@
<div class="toolbar-group" id="csv-tools" style="display: none;"> <div class="toolbar-group" id="csv-tools" style="display: none;">
<button <button
class="btn btn-small" class="btn btn-small"
hx-post="/api/v1/editor/csv/add-row" hx-post="/api/editor/csv/add-row"
hx-target="#csv-table-body" hx-target="#csv-table-body"
hx-swap="beforeend"> hx-swap="beforeend">
Row Row
</button> </button>
<button <button
class="btn btn-small" class="btn btn-small"
hx-post="/api/v1/editor/csv/add-column" hx-post="/api/editor/csv/add-column"
hx-target="#csv-editor" hx-target="#csv-editor"
hx-swap="innerHTML"> hx-swap="innerHTML">
Column Column
@ -482,7 +482,7 @@
<div <div
class="line-numbers" class="line-numbers"
id="line-numbers" id="line-numbers"
hx-get="/api/v1/editor/line-numbers" hx-get="/api/editor/line-numbers"
hx-trigger="keyup from:#text-editor delay:100ms" hx-trigger="keyup from:#text-editor delay:100ms"
hx-swap="innerHTML"> hx-swap="innerHTML">
1 1
@ -492,7 +492,7 @@
id="text-editor" id="text-editor"
name="content" name="content"
spellcheck="false" spellcheck="false"
hx-post="/api/v1/editor/autosave" hx-post="/api/editor/autosave"
hx-trigger="keyup changed delay:5s" hx-trigger="keyup changed delay:5s"
hx-swap="none" hx-swap="none"
hx-indicator="#autosave-indicator" hx-indicator="#autosave-indicator"
@ -511,7 +511,7 @@
class="csv-input" class="csv-input"
name="header_0" name="header_0"
value="Column 1" value="Column 1"
hx-post="/api/v1/editor/csv/update-header" hx-post="/api/editor/csv/update-header"
hx-trigger="change" hx-trigger="change"
hx-swap="none"> hx-swap="none">
</th> </th>
@ -519,7 +519,7 @@
</thead> </thead>
<tbody <tbody
id="csv-table-body" id="csv-table-body"
hx-get="/api/v1/editor/csv/rows" hx-get="/api/editor/csv/rows"
hx-trigger="load" hx-trigger="load"
hx-swap="innerHTML"> hx-swap="innerHTML">
</tbody> </tbody>
@ -532,7 +532,7 @@
<div class="status-left"> <div class="status-left">
<span <span
id="file-type" id="file-type"
hx-get="/api/v1/editor/filetype" hx-get="/api/editor/filetype"
hx-trigger="load" hx-trigger="load"
hx-swap="innerHTML"> hx-swap="innerHTML">
📄 Plain Text 📄 Plain Text
@ -548,7 +548,7 @@
<div class="status-right"> <div class="status-right">
<span <span
id="cursor-position" id="cursor-position"
hx-get="/api/v1/editor/position" hx-get="/api/editor/position"
hx-trigger="click from:#text-editor, keyup from:#text-editor" hx-trigger="click from:#text-editor, keyup from:#text-editor"
hx-swap="innerHTML"> hx-swap="innerHTML">
Ln 1, Col 1 Ln 1, Col 1
@ -587,7 +587,7 @@
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 's') { if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault(); e.preventDefault();
htmx.trigger(document.querySelector('[hx-post="/api/v1/editor/save"]'), 'click'); htmx.trigger(document.querySelector('[hx-post="/api/editor/save"]'), 'click');
} }
}); });
function showMagicPanel() { function showMagicPanel() {
@ -611,7 +611,7 @@
content.innerHTML = '<div class="magic-loading">✨ Analyzing your code...</div>'; content.innerHTML = '<div class="magic-loading">✨ Analyzing your code...</div>';
try { try {
const response = await fetch('/api/v1/editor/magic', { const response = await fetch('/api/editor/magic', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: code }) body: JSON.stringify({ code: code })

View file

@ -23,6 +23,6 @@ document.getElementById('text-editor')?.addEventListener('input', function() {
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 's') { if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault(); e.preventDefault();
htmx.trigger(document.querySelector('[hx-post="/api/v1/editor/save"]'), 'click'); htmx.trigger(document.querySelector('[hx-post="/api/editor/save"]'), 'click');
} }
}); });

View file

@ -1,350 +0,0 @@
<!-- Forms - Redirect to Tasks with AI Form Generation -->
<!-- This page redirects to Tasks with a pre-filled AI prompt to create forms -->
<link rel="stylesheet" href="/suite/forms/forms.css" />
<div class="forms-container">
<div class="forms-redirect-card">
<div class="forms-icon">
<svg
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<line x1="10" y1="9" x2="8" y2="9" />
</svg>
</div>
<h1 class="forms-title" data-i18n="forms-title">Create a Form</h1>
<p class="forms-description" data-i18n="forms-description">
Use AI to generate custom forms, surveys, and questionnaires.
Describe what you need and let the assistant build it for you.
</p>
<div class="forms-input-group">
<label class="forms-label" data-i18n="forms-what-form">
What kind of form do you need?
</label>
<input
type="text"
id="forms-topic"
class="forms-input"
placeholder="e.g., Customer feedback survey, Event registration, Contact form..."
data-i18n-placeholder="forms-topic-placeholder"
autofocus
/>
</div>
<div class="forms-examples">
<span class="forms-examples-label" data-i18n="forms-examples"
>Quick examples:</span
>
<div class="forms-example-chips">
<button
class="forms-chip"
data-topic="customer satisfaction survey"
>
Customer Survey
</button>
<button class="forms-chip" data-topic="event registration form">
Event Registration
</button>
<button class="forms-chip" data-topic="job application form">
Job Application
</button>
<button
class="forms-chip"
data-topic="contact form with name, email and message"
>
Contact Form
</button>
<button class="forms-chip" data-topic="product feedback form">
Product Feedback
</button>
<button
class="forms-chip"
data-topic="newsletter subscription form"
>
Newsletter Signup
</button>
</div>
</div>
<button class="forms-submit-btn" id="forms-create-btn">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2z"
/>
<circle cx="7.5" cy="14.5" r="1.5" />
<circle cx="16.5" cy="14.5" r="1.5" />
</svg>
<span data-i18n="forms-create">Create Form with AI</span>
</button>
<p class="forms-hint" data-i18n="forms-hint">
You'll be redirected to Tasks where AI will generate your form
</p>
</div>
</div>
<script>
(function () {
const topicInput = document.getElementById("forms-topic");
const createBtn = document.getElementById("forms-create-btn");
const chips = document.querySelectorAll(".forms-chip");
// Handle chip clicks
chips.forEach((chip) => {
chip.addEventListener("click", function () {
topicInput.value = this.dataset.topic;
topicInput.focus();
});
});
// Handle create button
createBtn.addEventListener("click", function () {
redirectToTasks();
});
// Handle Enter key
topicInput.addEventListener("keydown", function (e) {
if (e.key === "Enter") {
redirectToTasks();
}
});
function redirectToTasks() {
const topic = topicInput.value.trim();
if (!topic) {
topicInput.focus();
topicInput.classList.add("error");
setTimeout(() => topicInput.classList.remove("error"), 1000);
return;
}
const prompt = `Create a form for me about: ${topic}`;
// Navigate to tasks with the AI prompt
// The prompt will be passed as a query parameter or stored in sessionStorage
sessionStorage.setItem("ai-task-prompt", prompt);
// Use HTMX to navigate to tasks
window.location.hash = "#tasks";
htmx.ajax("GET", "/suite/tasks/tasks.html", "#main-content").then(
() => {
// After tasks loads, trigger the AI prompt
setTimeout(() => {
const taskInput = document.querySelector(
"#task-ai-input, .task-input, [data-ai-input]",
);
if (taskInput) {
taskInput.value = prompt;
taskInput.focus();
// Trigger input event for any listeners
taskInput.dispatchEvent(
new Event("input", { bubbles: true }),
);
}
}, 300);
},
);
}
// Initialize i18n if available
if (window.i18n && window.i18n.translatePage) {
window.i18n.translatePage();
}
})();
</script>
<style>
.forms-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100%;
padding: 40px 24px;
background: var(--bg-primary, #0a0a0a);
}
.forms-redirect-card {
max-width: 560px;
width: 100%;
padding: 48px;
background: var(--surface, rgba(255, 255, 255, 0.02));
border: 1px solid var(--border, #2a2a2a);
border-radius: 16px;
text-align: center;
}
.forms-icon {
margin-bottom: 24px;
color: var(--accent, #d4f505);
}
.forms-title {
font-size: 28px;
font-weight: 700;
color: var(--text, #f8fafc);
margin: 0 0 12px 0;
}
.forms-description {
font-size: 15px;
line-height: 1.6;
color: var(--text-secondary, #888);
margin: 0 0 32px 0;
}
.forms-input-group {
text-align: left;
margin-bottom: 24px;
}
.forms-label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text, #f8fafc);
margin-bottom: 8px;
}
.forms-input {
width: 100%;
padding: 14px 16px;
background: var(--bg-primary, #0a0a0a);
border: 2px solid var(--border, #2a2a2a);
border-radius: 10px;
color: var(--text, #f8fafc);
font-size: 15px;
transition: all 0.2s ease;
}
.forms-input:focus {
outline: none;
border-color: var(--accent, #d4f505);
box-shadow: 0 0 0 4px rgba(212, 245, 5, 0.1);
}
.forms-input.error {
border-color: var(--error, #ef4444);
animation: shake 0.3s ease;
}
.forms-input::placeholder {
color: var(--text-secondary, #666);
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-4px);
}
75% {
transform: translateX(4px);
}
}
.forms-examples {
text-align: left;
margin-bottom: 32px;
}
.forms-examples-label {
display: block;
font-size: 12px;
color: var(--text-secondary, #888);
margin-bottom: 10px;
}
.forms-example-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.forms-chip {
padding: 8px 14px;
background: transparent;
border: 1px solid var(--border, #2a2a2a);
border-radius: 20px;
color: var(--text-secondary, #888);
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
}
.forms-chip:hover {
border-color: var(--accent, #d4f505);
color: var(--accent, #d4f505);
background: rgba(212, 245, 5, 0.05);
}
.forms-submit-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 16px 24px;
background: var(--accent, #d4f505);
color: #000;
border: none;
border-radius: 10px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.forms-submit-btn:hover {
background: var(--accent-hover, #c4e505);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(212, 245, 5, 0.2);
}
.forms-submit-btn:active {
transform: translateY(0);
}
.forms-hint {
font-size: 12px;
color: var(--text-secondary, #666);
margin: 16px 0 0 0;
}
@media (max-width: 640px) {
.forms-redirect-card {
padding: 32px 24px;
}
.forms-title {
font-size: 24px;
}
.forms-icon svg {
width: 48px;
height: 48px;
}
}
</style>

View file

@ -1001,13 +1001,13 @@
</a> </a>
<a <a
href="#compliance" href="#security"
class="app-card" class="app-card"
hx-get="/suite/tools/compliance.html" hx-get="/suite/tools/security.html"
hx-target="#main-content" hx-target="#main-content"
hx-push-url="/#compliance" hx-push-url="/#security"
> >
<div class="app-icon compliance"> <div class="app-icon security">
<svg <svg
width="28" width="28"
height="28" height="28"
@ -1021,10 +1021,10 @@
</svg> </svg>
</div> </div>
<div class="app-content"> <div class="app-content">
<h3>Compliance</h3> <h3>Security</h3>
<p> <p>
Security, auditing, and compliance tools. Monitor Security tools, compliance scanning, and server
access, track changes, and ensure data protection. protection. Lynis, RKHunter, ClamAV, and more.
</p> </p>
</div> </div>
</a> </a>

View file

@ -908,37 +908,6 @@
<span data-i18n="nav-tickets">Tickets</span> <span data-i18n="nav-tickets">Tickets</span>
</a> </a>
<!-- Forms -->
<a
class="app-item"
href="#forms"
data-section="forms"
role="menuitem"
aria-label="Forms - Create forms with AI"
hx-get="/suite/forms/forms.html"
hx-target="#main-content"
hx-push-url="/#forms"
>
<div class="app-icon" aria-hidden="true">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="18" x2="12" y2="12" />
<line x1="9" y1="15" x2="15" y2="15" />
</svg>
</div>
<span data-i18n="nav-forms">Forms</span>
</a>
<!-- Paper --> <!-- Paper -->
<a <a
class="app-item" class="app-item"
@ -1207,16 +1176,16 @@
<span data-i18n="nav-sources">Sources</span> <span data-i18n="nav-sources">Sources</span>
</a> </a>
<!-- Compliance --> <!-- Security -->
<a <a
class="app-item" class="app-item"
href="#compliance" href="#security"
data-section="compliance" data-section="security"
role="menuitem" role="menuitem"
aria-label="Compliance" aria-label="Security"
hx-get="/suite/tools/compliance.html" hx-get="/suite/tools/security.html"
hx-target="#main-content" hx-target="#main-content"
hx-push-url="/#compliance" hx-push-url="/#security"
> >
<div class="app-icon" aria-hidden="true"> <div class="app-icon" aria-hidden="true">
<svg <svg
@ -1233,9 +1202,7 @@
<path d="M9 12l2 2 4-4" /> <path d="M9 12l2 2 4-4" />
</svg> </svg>
</div> </div>
<span data-i18n="nav-compliance" <span data-i18n="nav-security">Security</span>
>Compliance</span
>
</a> </a>
<!-- Designer (.bas Editor) --> <!-- Designer (.bas Editor) -->

293
ui/suite/js/i18n.js Normal file
View file

@ -0,0 +1,293 @@
(function () {
"use strict";
const DEFAULT_LOCALE = "en";
const STORAGE_KEY = "gb-locale";
const CACHE_TTL_MS = 3600000;
const MINIMAL_FALLBACK = {
"label-loading": "Loading...",
"status-error": "Error",
"action-retry": "Retry",
};
let currentLocale = DEFAULT_LOCALE;
let translations = {};
let isInitialized = false;
function detectBrowserLocale() {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
return stored;
}
const browserLang =
navigator.language || navigator.userLanguage || DEFAULT_LOCALE;
const shortLang = browserLang.split("-")[0];
const supportedLocales = ["en", "pt-BR", "es", "zh-CN"];
if (supportedLocales.includes(browserLang)) {
return browserLang;
}
const match = supportedLocales.find((loc) => loc.startsWith(shortLang));
return match || DEFAULT_LOCALE;
}
function getCacheKey(locale) {
return `gb-i18n-cache-${locale}`;
}
function getCachedTranslations(locale) {
try {
const cached = localStorage.getItem(getCacheKey(locale));
if (cached) {
const { data, timestamp } = JSON.parse(cached);
if (Date.now() - timestamp < CACHE_TTL_MS) {
return data;
}
}
} catch (e) {
console.warn("i18n: Failed to read cache", e);
}
return null;
}
function setCachedTranslations(locale, data) {
try {
localStorage.setItem(
getCacheKey(locale),
JSON.stringify({
data,
timestamp: Date.now(),
}),
);
} catch (e) {
console.warn("i18n: Failed to write cache", e);
}
}
async function fetchTranslations(locale) {
try {
const response = await fetch(`/api/i18n/${locale}`, {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
return result.translations || {};
} catch (e) {
console.warn(`i18n: Failed to fetch translations for ${locale}`, e);
return null;
}
}
async function loadTranslations(locale) {
const cached = getCachedTranslations(locale);
if (cached) {
translations = cached;
currentLocale = locale;
return true;
}
const fetched = await fetchTranslations(locale);
if (fetched && Object.keys(fetched).length > 0) {
translations = fetched;
currentLocale = locale;
setCachedTranslations(locale, fetched);
return true;
}
if (locale !== DEFAULT_LOCALE) {
console.warn(`i18n: Falling back to ${DEFAULT_LOCALE}`);
return loadTranslations(DEFAULT_LOCALE);
}
translations = MINIMAL_FALLBACK;
return false;
}
function t(key, params) {
let text = translations[key] || MINIMAL_FALLBACK[key] || key;
if (params && typeof params === "object") {
Object.keys(params).forEach((param) => {
text = text.replace(
new RegExp(`\\{\\s*\\$?${param}\\s*\\}`, "g"),
params[param],
);
text = text.replace(
new RegExp(`\\{\\s*${param}\\s*\\}`, "g"),
params[param],
);
});
}
return text;
}
function translateElement(element) {
const key = element.getAttribute("data-i18n");
if (key) {
const paramsAttr = element.getAttribute("data-i18n-params");
let params = null;
if (paramsAttr) {
try {
params = JSON.parse(paramsAttr);
} catch (e) {
console.warn("i18n: Invalid params JSON", paramsAttr);
}
}
element.textContent = t(key, params);
}
const placeholderKey = element.getAttribute("data-i18n-placeholder");
if (placeholderKey) {
element.setAttribute("placeholder", t(placeholderKey));
}
const titleKey = element.getAttribute("data-i18n-title");
if (titleKey) {
element.setAttribute("title", t(titleKey));
}
const ariaLabelKey = element.getAttribute("data-i18n-aria-label");
if (ariaLabelKey) {
element.setAttribute("aria-label", t(ariaLabelKey));
}
}
function translatePage(root) {
const container = root || document;
const elements = container.querySelectorAll(
"[data-i18n], [data-i18n-placeholder], [data-i18n-title], [data-i18n-aria-label]",
);
elements.forEach(translateElement);
}
async function setLocale(locale) {
if (locale === currentLocale && isInitialized) {
return;
}
localStorage.setItem(STORAGE_KEY, locale);
await loadTranslations(locale);
translatePage();
document.documentElement.setAttribute("lang", locale.split("-")[0]);
window.dispatchEvent(
new CustomEvent("localeChanged", {
detail: { locale: currentLocale },
}),
);
}
function setupBodyListeners() {
if (!document.body) {
return;
}
document.body.addEventListener("htmx:afterSwap", (event) => {
translatePage(event.detail.target);
});
document.body.addEventListener("htmx:afterSettle", (event) => {
translatePage(event.detail.target);
});
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
if (
node.hasAttribute &&
(node.hasAttribute("data-i18n") ||
node.hasAttribute("data-i18n-placeholder") ||
node.hasAttribute("data-i18n-title") ||
node.hasAttribute("data-i18n-aria-label"))
) {
translateElement(node);
}
if (node.querySelectorAll) {
translatePage(node);
}
}
});
});
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
}
async function init() {
if (isInitialized) {
return;
}
const locale = detectBrowserLocale();
await loadTranslations(locale);
isInitialized = true;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
translatePage();
setupBodyListeners();
});
} else {
translatePage();
setupBodyListeners();
}
}
async function getAvailableLocales() {
try {
const response = await fetch("/api/i18n/locales");
if (response.ok) {
const data = await response.json();
return data.locales || ["en"];
}
} catch (e) {
console.warn("i18n: Failed to fetch available locales", e);
}
return ["en", "pt-BR", "es"];
}
function getCurrentLocale() {
return currentLocale;
}
function clearCache() {
const keys = Object.keys(localStorage);
keys.forEach((key) => {
if (key.startsWith("gb-i18n-cache-")) {
localStorage.removeItem(key);
}
});
}
window.i18n = {
t,
init,
setLocale,
getCurrentLocale,
getAvailableLocales,
translatePage,
translateElement,
clearCache,
};
init().catch((e) => console.error("i18n: Initialization failed", e));
})();

View file

@ -512,7 +512,7 @@
<div class="compliance-actions"> <div class="compliance-actions">
<button <button
class="compliance-btn" class="compliance-btn"
hx-get="/api/v1/compliance/export" hx-get="/api/compliance/export"
hx-swap="none" hx-swap="none"
> >
<svg <svg
@ -532,7 +532,7 @@
<button <button
class="compliance-btn compliance-btn-primary" class="compliance-btn compliance-btn-primary"
id="scan-btn" id="scan-btn"
hx-post="/api/v1/compliance/scan" hx-post="/api/compliance/scan"
hx-target="#compliance-results-body" hx-target="#compliance-results-body"
hx-indicator="#scan-progress" hx-indicator="#scan-progress"
> >

2674
ui/suite/tools/security.html Normal file

File diff suppressed because it is too large Load diff

View file

@ -208,7 +208,7 @@
*/ */
function exportReport() { function exportReport() {
if (typeof htmx !== 'undefined') { if (typeof htmx !== 'undefined') {
htmx.ajax('GET', '/api/v1/compliance/export', { htmx.ajax('GET', '/api/compliance/export', {
swap: 'none' swap: 'none'
}); });
} }
@ -219,7 +219,7 @@
*/ */
function fixIssue(issueId) { function fixIssue(issueId) {
if (typeof htmx !== 'undefined') { if (typeof htmx !== 'undefined') {
htmx.ajax('POST', `/api/v1/compliance/fix/${issueId}`, { htmx.ajax('POST', `/api/compliance/fix/${issueId}`, {
swap: 'none' swap: 'none'
}).then(() => { }).then(() => {
// Refresh results // Refresh results