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:
parent
9a4c8bf6a6
commit
955568c8e4
14 changed files with 3020 additions and 660 deletions
232
TODO.md
232
TODO.md
|
|
@ -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
|
||||
8
ui/suite/assets/icons/gb-security.svg
Normal file
8
ui/suite/assets/icons/gb-security.svg
Normal 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 |
|
|
@ -312,7 +312,7 @@
|
|||
.app-icon.attendant {
|
||||
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||
}
|
||||
.app-icon.compliance {
|
||||
.app-icon.security {
|
||||
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
|
||||
}
|
||||
.app-icon.monitoring {
|
||||
|
|
|
|||
|
|
@ -904,7 +904,7 @@
|
|||
</button>
|
||||
|
||||
<button class="toolbar-btn" id="btn-open"
|
||||
hx-get="/api/v1/designer/files"
|
||||
hx-get="/api/designer/files"
|
||||
hx-target="#file-list-content"
|
||||
hx-trigger="click"
|
||||
onclick="showModal('open-modal')"
|
||||
|
|
@ -916,7 +916,7 @@
|
|||
</button>
|
||||
|
||||
<button class="toolbar-btn primary" id="btn-save"
|
||||
hx-post="/api/v1/designer/save"
|
||||
hx-post="/api/designer/save"
|
||||
hx-include="#designer-data"
|
||||
hx-indicator="#save-spinner"
|
||||
title="Save (Ctrl+S)">
|
||||
|
|
@ -948,7 +948,7 @@
|
|||
<div class="toolbar-separator"></div>
|
||||
|
||||
<button class="toolbar-btn" id="btn-run"
|
||||
hx-post="/api/v1/designer/validate"
|
||||
hx-post="/api/designer/validate"
|
||||
hx-include="#designer-data"
|
||||
title="Validate Dialog">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
|
|
@ -958,7 +958,7 @@
|
|||
</button>
|
||||
|
||||
<button class="toolbar-btn" id="btn-export"
|
||||
hx-get="/api/v1/designer/export"
|
||||
hx-get="/api/designer/export"
|
||||
hx-include="#designer-data"
|
||||
title="Export as .bas">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
|
|
@ -1374,7 +1374,7 @@
|
|||
<div class="modal-footer">
|
||||
<button class="toolbar-btn" onclick="hideModal('open-modal')">Cancel</button>
|
||||
<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-target="#canvas-inner"
|
||||
hx-swap="innerHTML"
|
||||
|
|
@ -2372,7 +2372,7 @@
|
|||
if (state.driveSource) {
|
||||
saveToDrive();
|
||||
} else {
|
||||
htmx.ajax('POST', '/api/v1/designer/save', {
|
||||
htmx.ajax('POST', '/api/designer/save', {
|
||||
source: document.getElementById('designer-data'),
|
||||
target: '#status-message'
|
||||
});
|
||||
|
|
@ -2594,7 +2594,7 @@
|
|||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/designer/magic', {
|
||||
const response = await fetch('/api/designer/magic', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(dialogData)
|
||||
|
|
|
|||
|
|
@ -807,7 +807,7 @@ function saveDesign() {
|
|||
|
||||
// Trigger HTMX save if available
|
||||
if (typeof htmx !== 'undefined') {
|
||||
htmx.ajax('POST', '/api/v1/designer/save', {
|
||||
htmx.ajax('POST', '/api/designer/save', {
|
||||
source: document.getElementById('designer-data'),
|
||||
target: '#status-message'
|
||||
});
|
||||
|
|
|
|||
|
|
@ -359,7 +359,7 @@
|
|||
<span
|
||||
class="editor-title-text"
|
||||
id="editor-filename"
|
||||
hx-get="/api/v1/editor/filename"
|
||||
hx-get="/api/editor/filename"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML"></div>
|
||||
Untitled
|
||||
|
|
@ -367,7 +367,7 @@
|
|||
<div
|
||||
class="editor-path"
|
||||
id="editor-filepath"
|
||||
hx-get="/api/v1/editor/filepath"
|
||||
hx-get="/api/editor/filepath"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
</div>
|
||||
|
|
@ -395,7 +395,7 @@
|
|||
<div class="toolbar-group">
|
||||
<button
|
||||
class="btn btn-primary btn-small"
|
||||
hx-post="/api/v1/editor/save"
|
||||
hx-post="/api/editor/save"
|
||||
hx-include="#text-editor"
|
||||
hx-indicator="#save-spinner"
|
||||
hx-swap="none"
|
||||
|
|
@ -405,7 +405,7 @@
|
|||
</button>
|
||||
<button
|
||||
class="btn btn-small"
|
||||
hx-get="/api/v1/editor/save-as"
|
||||
hx-get="/api/editor/save-as"
|
||||
hx-target="#save-dialog"
|
||||
hx-swap="innerHTML">
|
||||
Save As
|
||||
|
|
@ -415,14 +415,14 @@
|
|||
<div class="toolbar-group">
|
||||
<button
|
||||
class="btn btn-small"
|
||||
hx-post="/api/v1/editor/undo"
|
||||
hx-post="/api/editor/undo"
|
||||
hx-target="#editor-content"
|
||||
hx-swap="innerHTML">
|
||||
↩️ Undo
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-small"
|
||||
hx-post="/api/v1/editor/redo"
|
||||
hx-post="/api/editor/redo"
|
||||
hx-target="#editor-content"
|
||||
hx-swap="innerHTML">
|
||||
↪️ Redo
|
||||
|
|
@ -432,7 +432,7 @@
|
|||
<div class="toolbar-group" id="text-tools">
|
||||
<button
|
||||
class="btn btn-small"
|
||||
hx-post="/api/v1/editor/format"
|
||||
hx-post="/api/editor/format"
|
||||
hx-include="#text-editor"
|
||||
hx-target="#text-editor"
|
||||
hx-swap="innerHTML">
|
||||
|
|
@ -449,14 +449,14 @@
|
|||
<div class="toolbar-group" id="csv-tools" style="display: none;">
|
||||
<button
|
||||
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-swap="beforeend">
|
||||
➕ Row
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-small"
|
||||
hx-post="/api/v1/editor/csv/add-column"
|
||||
hx-post="/api/editor/csv/add-column"
|
||||
hx-target="#csv-editor"
|
||||
hx-swap="innerHTML">
|
||||
➕ Column
|
||||
|
|
@ -482,7 +482,7 @@
|
|||
<div
|
||||
class="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-swap="innerHTML">
|
||||
1
|
||||
|
|
@ -492,7 +492,7 @@
|
|||
id="text-editor"
|
||||
name="content"
|
||||
spellcheck="false"
|
||||
hx-post="/api/v1/editor/autosave"
|
||||
hx-post="/api/editor/autosave"
|
||||
hx-trigger="keyup changed delay:5s"
|
||||
hx-swap="none"
|
||||
hx-indicator="#autosave-indicator"
|
||||
|
|
@ -511,7 +511,7 @@
|
|||
class="csv-input"
|
||||
name="header_0"
|
||||
value="Column 1"
|
||||
hx-post="/api/v1/editor/csv/update-header"
|
||||
hx-post="/api/editor/csv/update-header"
|
||||
hx-trigger="change"
|
||||
hx-swap="none">
|
||||
</th>
|
||||
|
|
@ -519,7 +519,7 @@
|
|||
</thead>
|
||||
<tbody
|
||||
id="csv-table-body"
|
||||
hx-get="/api/v1/editor/csv/rows"
|
||||
hx-get="/api/editor/csv/rows"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
</tbody>
|
||||
|
|
@ -532,7 +532,7 @@
|
|||
<div class="status-left">
|
||||
<span
|
||||
id="file-type"
|
||||
hx-get="/api/v1/editor/filetype"
|
||||
hx-get="/api/editor/filetype"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
📄 Plain Text
|
||||
|
|
@ -548,7 +548,7 @@
|
|||
<div class="status-right">
|
||||
<span
|
||||
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-swap="innerHTML">
|
||||
Ln 1, Col 1
|
||||
|
|
@ -587,7 +587,7 @@
|
|||
document.addEventListener('keydown', function(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
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() {
|
||||
|
|
@ -611,7 +611,7 @@
|
|||
content.innerHTML = '<div class="magic-loading">✨ Analyzing your code...</div>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/editor/magic', {
|
||||
const response = await fetch('/api/editor/magic', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: code })
|
||||
|
|
|
|||
|
|
@ -23,6 +23,6 @@ document.getElementById('text-editor')?.addEventListener('input', function() {
|
|||
document.addEventListener('keydown', function(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
htmx.trigger(document.querySelector('[hx-post="/api/v1/editor/save"]'), 'click');
|
||||
htmx.trigger(document.querySelector('[hx-post="/api/editor/save"]'), 'click');
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -1001,13 +1001,13 @@
|
|||
</a>
|
||||
|
||||
<a
|
||||
href="#compliance"
|
||||
href="#security"
|
||||
class="app-card"
|
||||
hx-get="/suite/tools/compliance.html"
|
||||
hx-get="/suite/tools/security.html"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="/#compliance"
|
||||
hx-push-url="/#security"
|
||||
>
|
||||
<div class="app-icon compliance">
|
||||
<div class="app-icon security">
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
|
|
@ -1021,10 +1021,10 @@
|
|||
</svg>
|
||||
</div>
|
||||
<div class="app-content">
|
||||
<h3>Compliance</h3>
|
||||
<h3>Security</h3>
|
||||
<p>
|
||||
Security, auditing, and compliance tools. Monitor
|
||||
access, track changes, and ensure data protection.
|
||||
Security tools, compliance scanning, and server
|
||||
protection. Lynis, RKHunter, ClamAV, and more.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -908,37 +908,6 @@
|
|||
<span data-i18n="nav-tickets">Tickets</span>
|
||||
</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 -->
|
||||
<a
|
||||
class="app-item"
|
||||
|
|
@ -1207,16 +1176,16 @@
|
|||
<span data-i18n="nav-sources">Sources</span>
|
||||
</a>
|
||||
|
||||
<!-- Compliance -->
|
||||
<!-- Security -->
|
||||
<a
|
||||
class="app-item"
|
||||
href="#compliance"
|
||||
data-section="compliance"
|
||||
href="#security"
|
||||
data-section="security"
|
||||
role="menuitem"
|
||||
aria-label="Compliance"
|
||||
hx-get="/suite/tools/compliance.html"
|
||||
aria-label="Security"
|
||||
hx-get="/suite/tools/security.html"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="/#compliance"
|
||||
hx-push-url="/#security"
|
||||
>
|
||||
<div class="app-icon" aria-hidden="true">
|
||||
<svg
|
||||
|
|
@ -1233,9 +1202,7 @@
|
|||
<path d="M9 12l2 2 4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<span data-i18n="nav-compliance"
|
||||
>Compliance</span
|
||||
>
|
||||
<span data-i18n="nav-security">Security</span>
|
||||
</a>
|
||||
|
||||
<!-- Designer (.bas Editor) -->
|
||||
|
|
|
|||
293
ui/suite/js/i18n.js
Normal file
293
ui/suite/js/i18n.js
Normal 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));
|
||||
})();
|
||||
|
|
@ -512,7 +512,7 @@
|
|||
<div class="compliance-actions">
|
||||
<button
|
||||
class="compliance-btn"
|
||||
hx-get="/api/v1/compliance/export"
|
||||
hx-get="/api/compliance/export"
|
||||
hx-swap="none"
|
||||
>
|
||||
<svg
|
||||
|
|
@ -532,7 +532,7 @@
|
|||
<button
|
||||
class="compliance-btn compliance-btn-primary"
|
||||
id="scan-btn"
|
||||
hx-post="/api/v1/compliance/scan"
|
||||
hx-post="/api/compliance/scan"
|
||||
hx-target="#compliance-results-body"
|
||||
hx-indicator="#scan-progress"
|
||||
>
|
||||
|
|
|
|||
2674
ui/suite/tools/security.html
Normal file
2674
ui/suite/tools/security.html
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -208,7 +208,7 @@
|
|||
*/
|
||||
function exportReport() {
|
||||
if (typeof htmx !== 'undefined') {
|
||||
htmx.ajax('GET', '/api/v1/compliance/export', {
|
||||
htmx.ajax('GET', '/api/compliance/export', {
|
||||
swap: 'none'
|
||||
});
|
||||
}
|
||||
|
|
@ -219,7 +219,7 @@
|
|||
*/
|
||||
function fixIssue(issueId) {
|
||||
if (typeof htmx !== 'undefined') {
|
||||
htmx.ajax('POST', `/api/v1/compliance/fix/${issueId}`, {
|
||||
htmx.ajax('POST', `/api/compliance/fix/${issueId}`, {
|
||||
swap: 'none'
|
||||
}).then(() => {
|
||||
// Refresh results
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue