UI updates
All checks were successful
BotUI CI / build (push) Successful in 5m13s

This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2026-03-15 16:01:50 -03:00
parent 516a38777c
commit 5943ad452d
6 changed files with 810 additions and 113 deletions

View file

@ -770,7 +770,9 @@ fn create_api_router() -> Router<AppState> {
#[derive(Debug, Deserialize)]
struct WsQuery {
#[allow(dead_code)]
session_id: String,
#[allow(dead_code)]
user_id: String,
bot_name: Option<String>,
}
@ -1092,69 +1094,6 @@ async fn handle_task_progress_ws_proxy(
}
}
async fn forward_client_to_backend(
client_rx: &mut futures_util::stream::SplitStream<WebSocket>,
backend_tx: &mut futures_util::stream::SplitSink<tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>, TungsteniteMessage>,
) {
while let Some(msg) = client_rx.next().await {
match msg {
Ok(AxumMessage::Text(text)) => {
if backend_tx.send(TungsteniteMessage::Text(text)).await.is_err() {
break;
}
}
Ok(AxumMessage::Binary(data)) => {
if backend_tx.send(TungsteniteMessage::Binary(data)).await.is_err() {
break;
}
}
Ok(AxumMessage::Ping(data)) => {
if backend_tx.send(TungsteniteMessage::Ping(data)).await.is_err() {
break;
}
}
Ok(AxumMessage::Pong(data)) => {
if backend_tx.send(TungsteniteMessage::Pong(data)).await.is_err() {
break;
}
}
Ok(AxumMessage::Close(_)) | Err(_) => break,
}
}
}
async fn forward_backend_to_client(
backend_rx: &mut futures_util::stream::SplitStream<tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>>,
client_tx: &mut futures_util::stream::SplitSink<WebSocket, AxumMessage>,
) {
while let Some(msg) = backend_rx.next().await {
match msg {
Ok(TungsteniteMessage::Text(text)) => {
if client_tx.send(AxumMessage::Text(text)).await.is_err() {
break;
}
}
Ok(TungsteniteMessage::Binary(data)) => {
if client_tx.send(AxumMessage::Binary(data)).await.is_err() {
break;
}
}
Ok(TungsteniteMessage::Ping(data)) => {
if client_tx.send(AxumMessage::Ping(data)).await.is_err() {
break;
}
}
Ok(TungsteniteMessage::Pong(data)) => {
if client_tx.send(AxumMessage::Pong(data)).await.is_err() {
break;
}
}
Ok(TungsteniteMessage::Close(_)) | Err(_) => break,
Ok(_) => {}
}
}
}
fn create_ws_router() -> Router<AppState> {
Router::new()
.route("/task-progress", get(ws_task_progress_proxy))

View file

@ -11,6 +11,8 @@
<h1 data-i18n="campaigns-title">Campaigns</h1>
<nav class="crm-tabs">
<button class="crm-tab active" data-view="all" data-i18n="campaigns-all">All Campaigns</button>
<button class="crm-tab" data-view="lists" data-i18n="campaigns-lists">Lists</button>
<button class="crm-tab" data-view="templates" data-i18n="campaigns-templates">Templates</button>
<button class="crm-tab" data-view="email" data-i18n="campaigns-email">Email</button>
<button class="crm-tab" data-view="whatsapp" data-i18n="campaigns-whatsapp">WhatsApp</button>
<button class="crm-tab" data-view="social" data-i18n="campaigns-social">Social</button>
@ -40,6 +42,44 @@
</div>
</div>
</div>
<!-- Lists Grid -->
<div id="lists-view" class="crm-view">
<div class="crm-view-header" style="padding: 16px 24px; display: flex; justify-content: flex-end;">
<button class="btn-primary" onclick="showListModal()">
<span>New List</span>
</button>
</div>
<div class="campaigns-grid" id="listsList"
hx-get="/api/crm/lists"
hx-trigger="load"
hx-swap="innerHTML">
<div class="pipeline-column" style="grid-column: 1 / -1;">
<div style="padding: 40px; text-align: center; color: var(--text-secondary);">
Loading lists...
</div>
</div>
</div>
</div>
<!-- Templates Grid -->
<div id="templates-view" class="crm-view">
<div class="crm-view-header" style="padding: 16px 24px; display: flex; justify-content: flex-end;">
<button class="btn-primary" onclick="showTemplateModal()">
<span>New Template</span>
</button>
</div>
<div class="campaigns-grid" id="templatesList"
hx-get="/api/crm/templates"
hx-trigger="load"
hx-swap="innerHTML">
<div class="pipeline-column" style="grid-column: 1 / -1;">
<div style="padding: 40px; text-align: center; color: var(--text-secondary);">
Loading templates...
</div>
</div>
</div>
</div>
</div>
<!-- Create/Edit Campaign Modal -->
@ -256,6 +296,14 @@ function hideCampaignModal() {
document.getElementById('campaign-form').reset();
}
function showListModal() {
alert('List creation coming soon!');
}
function showTemplateModal() {
alert('Template creation coming soon!');
}
document.getElementById('campaign-form').addEventListener('htmx:afterRequest', function(e) {
if (e.detail.successful) {
hideCampaignModal();
@ -267,10 +315,21 @@ document.getElementById('campaign-form').addEventListener('htmx:afterRequest', f
document.querySelectorAll('.crm-tab[data-view]').forEach(tab => {
tab.addEventListener('click', function() {
document.querySelectorAll('.crm-tab[data-view]').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.crm-view').forEach(v => v.classList.remove('active'));
this.classList.add('active');
const view = this.dataset.view;
filterCampaigns(view);
// Show the appropriate view
const viewElement = document.getElementById(`${view}-view`);
if (viewElement) {
viewElement.classList.add('active');
}
// For channel filters, use the existing filter function
if (['all', 'email', 'whatsapp', 'social'].includes(view)) {
filterCampaigns(view);
}
});
});

View file

@ -13,8 +13,7 @@
<h1 data-i18n="crm-title">CRM</h1>
<nav class="crm-tabs">
<button class="crm-tab active" data-view="pipeline" data-i18n="crm-pipeline">Pipeline</button>
<button class="crm-tab" data-view="leads" data-i18n="crm-leads">Leads</button>
<button class="crm-tab" data-view="opportunities" data-i18n="crm-opportunities">Opportunities</button>
<button class="crm-tab" data-view="deals" data-i18n="crm-deals">Deals</button>
<button class="crm-tab" data-view="accounts" data-i18n="crm-accounts">Accounts</button>
<button class="crm-tab" data-view="contacts" data-i18n="crm-contacts">Contacts</button>
<button class="crm-tab" data-view="campaigns" data-i18n="crm-campaigns">Campaigns</button>
@ -26,8 +25,8 @@
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
<input type="text"
placeholder="Search leads, opportunities, accounts..."
data-i18n-placeholder="crm-search-placeholder"
placeholder="Search deals, accounts..."
data-i18n-placeholder="crm-search-placeholder-deals"
hx-get="/api/ui/crm/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#crm-search-results">
@ -154,53 +153,37 @@
</div>
</div>
<!-- Leads List View -->
<div id="crm-leads-view" class="crm-view">
<!-- Deals List View -->
<div id="crm-deals-view" class="crm-view">
<div class="crm-list-header">
<div class="list-filters">
<select hx-get="/api/crm/leads" hx-trigger="change" hx-target="#leads-table-body" hx-include="this">
<option value="all" data-i18n="crm-filter-all">All Leads</option>
<select hx-get="/api/crm/deals" hx-trigger="change" hx-target="#deals-table-body" hx-include="this">
<option value="all" data-i18n="crm-filter-all">All Deals</option>
<option value="new" data-i18n="crm-filter-new">New</option>
<option value="contacted" data-i18n="crm-filter-contacted">Contacted</option>
<option value="qualified" data-i18n="crm-filter-qualified">Qualified</option>
<option value="proposal" data-i18n="crm-filter-proposal">Proposal</option>
<option value="negotiation" data-i18n="crm-filter-negotiation">Negotiation</option>
<option value="won" data-i18n="crm-filter-won">Won</option>
<option value="lost" data-i18n="crm-filter-lost">Lost</option>
</select>
</div>
</div>
<table class="crm-table">
<thead>
<tr>
<th data-i18n="crm-col-name">Name</th>
<th data-i18n="crm-col-company">Company</th>
<th data-i18n="crm-col-email">Email</th>
<th data-i18n="crm-col-phone">Phone</th>
<th data-i18n="crm-col-source">Source</th>
<th data-i18n="crm-col-status">Status</th>
<th data-i18n="crm-col-created">Created</th>
<th data-i18n="crm-col-actions">Actions</th>
</tr>
</thead>
<tbody id="leads-table-body" hx-get="/api/ui/crm/leads" hx-trigger="load">
<!-- Leads loaded via HTMX -->
</tbody>
</table>
</div>
<!-- Opportunities List View -->
<div id="crm-opportunities-view" class="crm-view">
<table class="crm-table">
<thead>
<tr>
<th data-i18n="crm-col-opportunity">Opportunity</th>
<th data-i18n="crm-col-account">Account</th>
<th data-i18n="crm-col-title">Title</th>
<th data-i18n="crm-col-value">Value</th>
<th data-i18n="crm-col-stage">Stage</th>
<th data-i18n="crm-col-contact">Contact</th>
<th data-i18n="crm-col-account">Account</th>
<th data-i18n="crm-col-probability">Probability</th>
<th data-i18n="crm-col-close-date">Expected Close</th>
<th data-i18n="crm-col-owner">Owner</th>
<th data-i18n="crm-col-actions">Actions</th>
</tr>
</thead>
<tbody id="opportunities-table-body" hx-get="/api/ui/crm/opportunities" hx-trigger="load">
<tbody id="deals-table-body" hx-get="/api/ui/crm/deals" hx-trigger="load">
<!-- Deals loaded via HTMX -->
</tbody>
</table>
</div>

View file

@ -507,6 +507,25 @@
grid-auto-rows: 24px;
}
.virtual-viewport {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.virtual-content {
position: absolute;
top: 0;
left: 0;
}
.virtual-viewport .cell {
position: absolute;
box-sizing: border-box;
}
.cell {
min-width: 100px;
width: 100px;

View file

@ -207,6 +207,31 @@
</svg>
</button>
</div>
<span class="toolbar-divider"></span>
<div class="toolbar-group">
<button class="btn-icon" id="importXlsxBtn" title="Import xlsx/csv">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
</button>
<button class="btn-icon" id="exportXlsxBtn" title="Export xlsx">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
</button>
<button class="btn-icon" id="exportCsvBtn" title="Export CSV">
<svg width="16" height="16" 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"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
</svg>
</button>
</div>
</div>
<div class="toolbar-right">
<div class="collaborators" id="collaborators"></div>

View file

@ -13,8 +13,261 @@
MAX_HISTORY: 50,
AUTOSAVE_DELAY: 3000,
WS_RECONNECT_DELAY: 3000,
VIRTUAL_SCROLL_THRESHOLD: 500,
BUFFER_SIZE: 10,
};
let virtualGrid = null;
let useVirtualScroll = false;
class VirtualGrid {
constructor(container, options = {}) {
this.options = {
colCount: options.colCount || CONFIG.COLS,
rowCount: options.rowCount || CONFIG.ROWS,
colWidth: options.colWidth || CONFIG.COL_WIDTH,
rowHeight: options.rowHeight || CONFIG.ROW_HEIGHT,
bufferSize: options.bufferSize || CONFIG.BUFFER_SIZE,
...options
};
this.container = container;
this.cellCache = new Map();
this.renderedCells = new Map();
this.visibleStartRow = 0;
this.visibleEndRow = 0;
this.visibleStartCol = 0;
this.visibleEndCol = 0;
this.scrollLeft = 0;
this.scrollTop = 0;
this.isRendering = false;
this.initialize();
}
initialize() {
this.viewport = document.createElement('div');
this.viewport.className = 'virtual-viewport';
this.viewport.style.cssText = 'position:relative; overflow:auto; width:100%; height:100%;';
this.content = document.createElement('div');
this.content.className = 'virtual-content';
this.content.style.cssText = `position:absolute; top:0; left:0; width:${this.options.colCount * this.options.colWidth}px; height:${this.options.rowCount * this.options.rowHeight}px;`;
this.viewport.appendChild(this.content);
this.container.appendChild(this.viewport);
this.viewport.addEventListener('scroll', () => this.onScroll(), { passive: true });
this.rowHeaders = document.createElement('div');
this.rowHeaders.className = 'virtual-row-headers';
this.rowHeaders.style.cssText = 'position:sticky; left:0; z-index:10; display:flex; flex-direction:column;';
this.updateDimensions();
this.onScroll();
}
updateDimensions() {
this.content.style.width = `${this.options.colCount * this.options.colWidth}px`;
this.content.style.height = `${this.options.rowCount * this.options.rowHeight}px`;
}
onScroll() {
if (this.isRendering) return;
const lastScrollTop = this.scrollTop;
const lastScrollLeft = this.scrollLeft;
this.scrollTop = this.viewport.scrollTop;
this.scrollLeft = this.viewport.scrollLeft;
if (this.scrollTop === lastScrollTop && this.scrollLeft === lastScrollLeft) return;
requestAnimationFrame(() => this.renderVisibleCells());
}
renderVisibleCells() {
this.isRendering = true;
const viewHeight = this.viewport.clientHeight;
const viewWidth = this.viewport.clientWidth;
const buffer = this.options.bufferSize;
const newStartRow = Math.max(0, Math.floor(this.scrollTop / this.options.rowHeight) - buffer);
const newEndRow = Math.min(this.options.rowCount - 1, Math.ceil((this.scrollTop + viewHeight) / this.options.rowHeight) + buffer);
const newStartCol = Math.max(0, Math.floor(this.scrollLeft / this.options.colWidth) - buffer);
const newEndCol = Math.min(this.options.colCount - 1, Math.ceil((this.scrollLeft + viewWidth) / this.options.colWidth) + buffer);
if (newStartRow === this.visibleStartRow && newEndRow === this.visibleEndRow &&
newStartCol === this.visibleStartCol && newEndCol === this.visibleEndCol) {
this.isRendering = false;
return;
}
this.visibleStartRow = newStartRow;
this.visibleEndRow = newEndRow;
this.visibleStartCol = newStartCol;
this.visibleEndCol = newEndCol;
for (const [key, el] of this.renderedCells) {
const [r, c] = key.split(',').map(Number);
if (r < this.visibleStartRow || r > this.visibleEndRow ||
c < this.visibleStartCol || c > this.visibleEndCol) {
el.remove();
this.renderedCells.delete(key);
}
}
for (let row = this.visibleStartRow; row <= this.visibleEndRow; row++) {
for (let col = this.visibleStartCol; col <= this.visibleEndCol; col++) {
const key = `${row},${col}`;
const cellData = this.cellCache.get(key);
if (!this.renderedCells.has(key)) {
const cell = this.createCellElement(row, col, cellData);
cell.style.position = 'absolute';
cell.style.top = `${row * this.options.rowHeight}px`;
cell.style.left = `${col * this.options.colWidth}px`;
cell.style.width = `${this.options.colWidth}px`;
cell.style.height = `${this.options.rowHeight}px`;
cell.dataset.row = row;
cell.dataset.col = col;
this.content.appendChild(cell);
this.renderedCells.set(key, cell);
}
}
}
this.isRendering = false;
}
createCellElement(row, col, cellData) {
const cell = document.createElement('div');
cell.className = 'cell';
if (cellData) {
if (cellData.formula) {
cell.textContent = evaluateFormula(cellData.formula, row, col);
} else if (cellData.value !== undefined) {
cell.textContent = cellData.value;
}
if (cellData.style) {
this.applyStyle(cell, cellData.style);
}
if (cellData.merged) {
const { rowSpan, colSpan } = cellData.merged;
if (rowSpan > 1) cell.style.gridRow = `span ${rowSpan}`;
if (colSpan > 1) cell.style.gridColumn = `span ${colSpan}`;
}
}
return cell;
}
applyStyle(cell, style) {
if (!style) return;
if (style.fontFamily) cell.style.fontFamily = style.fontFamily;
if (style.fontSize) cell.style.fontSize = style.fontSize + 'px';
if (style.fontWeight) cell.style.fontWeight = style.fontWeight;
if (style.fontStyle) cell.style.fontStyle = style.fontStyle;
if (style.textDecoration) cell.style.textDecoration = style.textDecoration;
if (style.color) cell.style.color = style.color;
if (style.background) cell.style.backgroundColor = style.background;
if (style.textAlign) cell.style.textAlign = style.textAlign;
}
setCellValue(row, col, value) {
const key = `${row},${col}`;
if (!value || (typeof value === 'object' && !value.value && !value.formula)) {
this.cellCache.delete(key);
} else {
if (typeof value === 'object') {
this.cellCache.set(key, value);
} else {
this.cellCache.set(key, { value: String(value) });
}
}
if (row >= this.visibleStartRow && row <= this.visibleEndRow &&
col >= this.visibleStartCol && col <= this.visibleEndCol) {
const existing = this.renderedCells.get(key);
if (!value || (typeof value === 'object' && !value.value && !value.formula)) {
if (existing) {
existing.remove();
this.renderedCells.delete(key);
}
} else if (existing) {
const cell = this.createCellElement(row, col, typeof value === 'object' ? value : { value });
existing.textContent = cell.textContent;
existing.style.cssText = cell.style.cssText;
} else {
const cell = this.createCellElement(row, col, typeof value === 'object' ? value : { value });
cell.style.position = 'absolute';
cell.style.top = `${row * this.options.rowHeight}px`;
cell.style.left = `${col * this.options.colWidth}px`;
cell.style.width = `${this.options.colWidth}px`;
cell.style.height = `${this.options.rowHeight}px`;
cell.dataset.row = row;
cell.dataset.col = col;
this.content.appendChild(cell);
this.renderedCells.set(key, cell);
}
}
}
getCellValue(row, col) {
return this.cellCache.get(`${row},${col}`);
}
scrollToCell(row, col) {
const targetTop = row * this.options.rowHeight;
const targetLeft = col * this.options.colWidth;
const viewHeight = this.viewport.clientHeight;
const viewWidth = this.viewport.clientWidth;
this.viewport.scrollTo({
left: targetLeft - viewWidth / 2,
top: targetTop - viewHeight / 2,
behavior: 'smooth'
});
}
getVisibleRange() {
return {
startRow: this.visibleStartRow,
endRow: this.visibleEndRow,
startCol: this.visibleStartCol,
endCol: this.visibleEndCol
};
}
refresh() {
this.renderVisibleCells();
}
destroy() {
this.viewport.remove();
this.cellCache.clear();
this.renderedCells.clear();
}
loadData(data) {
this.cellCache.clear();
for (const [key, value] of Object.entries(data)) {
if (value && (value.value || value.formula || value.style)) {
this.cellCache.set(key, value);
}
}
this.refresh();
}
getViewportScroll() {
return { top: this.scrollTop, left: this.scrollLeft };
}
}
const state = {
sheetId: null,
sheetName: "Untitled Spreadsheet",
@ -44,6 +297,184 @@
const elements = {};
class AuditLog {
constructor() {
this.entries = [];
this.maxEntries = 1000;
}
log(action, details = {}) {
const entry = {
timestamp: new Date().toISOString(),
action,
details,
sheetId: state.sheetId
};
this.entries.push(entry);
if (this.entries.length > this.maxEntries) {
this.entries.shift();
}
this.persistEntry(entry);
}
async persistEntry(entry) {
if (!state.sheetId) return;
try {
await fetch('/api/sheet/audit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(entry)
});
} catch (e) {
console.warn('Audit log persist failed:', e);
}
}
getHistory(filter = {}) {
let filtered = this.entries;
if (filter.action) {
filtered = filtered.filter(e => e.action === filter.action);
}
if (filter.startTime) {
filtered = filtered.filter(e => new Date(e.timestamp) >= new Date(filter.startTime));
}
if (filter.endTime) {
filtered = filtered.filter(e => new Date(e.timestamp) <= new Date(filter.endTime));
}
return filtered;
}
}
class VersionManager {
constructor() {
this.versions = [];
this.currentVersion = -1;
this.maxVersions = 100;
this.autoSaveInterval = null;
this.lastSavedState = null;
}
createSnapshot(reason = 'manual') {
const snapshot = {
timestamp: new Date().toISOString(),
reason,
worksheets: JSON.parse(JSON.stringify(state.worksheets)),
sheetName: state.sheetName
};
if (this.lastSavedState && JSON.stringify(this.lastSavedState) === JSON.stringify(snapshot.worksheets)) {
return null;
}
this.versions.push(snapshot);
this.currentVersion = this.versions.length - 1;
this.lastSavedState = JSON.parse(JSON.stringify(snapshot.worksheets));
if (this.versions.length > this.maxVersions) {
this.versions.shift();
this.currentVersion--;
}
this.persistVersion(snapshot);
return this.versions.length - 1;
}
async persistVersion(snapshot) {
if (!state.sheetId) return;
try {
await fetch('/api/sheet/version', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sheetId: state.sheetId,
snapshot
})
});
} catch (e) {
console.warn('Version persist failed:', e);
}
}
restoreVersion(versionIndex) {
if (versionIndex < 0 || versionIndex >= this.versions.length) return false;
const version = this.versions[versionIndex];
state.worksheets = JSON.parse(JSON.stringify(version.worksheets));
state.sheetName = version.sheetName;
if (useVirtualScroll && virtualGrid) {
const ws = state.worksheets[state.activeWorksheet];
virtualGrid.loadData(ws?.data || {});
} else {
renderAllCells();
}
renderWorksheetTabs();
auditLog.log('version_restore', { versionIndex, timestamp: version.timestamp });
return true;
}
getVersionList() {
return this.versions.map((v, i) => ({
index: i,
timestamp: v.timestamp,
reason: v.reason,
sheetName: v.sheetName
})).reverse();
}
startAutoSave() {
if (this.autoSaveInterval) return;
this.autoSaveInterval = setInterval(() => {
if (state.isDirty) {
this.createSnapshot('auto');
}
}, 60000);
}
stopAutoSave() {
if (this.autoSaveInterval) {
clearInterval(this.autoSaveInterval);
this.autoSaveInterval = null;
}
}
}
class PermissionManager {
constructor() {
this.permissions = new Map();
this.currentUserLevel = 'edit';
}
setPermission(userId, level) {
this.permissions.set(userId, level);
}
setCurrentUserLevel(level) {
this.currentUserLevel = level;
}
canEdit() {
return this.currentUserLevel === 'edit' || this.currentUserLevel === 'admin';
}
canDelete() {
return this.currentUserLevel === 'admin';
}
canShare() {
return this.currentUserLevel === 'admin';
}
canExport() {
return this.currentUserLevel === 'view' || this.currentUserLevel === 'edit' || this.currentUserLevel === 'admin';
}
}
const auditLog = new AuditLog();
const versionManager = new VersionManager();
const permissions = new PermissionManager();
function init() {
cacheElements();
renderGrid();
@ -92,27 +523,85 @@
elements.insertImageModal = document.getElementById("insertImageModal");
}
function initVirtualGrid() {
const container = document.getElementById('cellsContainer');
if (!container || virtualGrid) return;
virtualGrid = new VirtualGrid(container, {
colCount: CONFIG.COLS,
rowCount: CONFIG.ROWS,
colWidth: CONFIG.COL_WIDTH,
rowHeight: CONFIG.ROW_HEIGHT
});
const ws = state.worksheets[state.activeWorksheet];
if (ws && ws.data) {
virtualGrid.loadData(ws.data);
}
}
function destroyVirtualGrid() {
if (virtualGrid) {
virtualGrid.destroy();
virtualGrid = null;
}
}
function renderGrid() {
renderColumnHeaders();
renderRowHeaders();
useVirtualScroll = CONFIG.ROWS > CONFIG.VIRTUAL_SCROLL_THRESHOLD;
if (useVirtualScroll) {
elements.cells.style.display = 'none';
if (!virtualGrid) {
initVirtualGrid();
} else {
virtualGrid.refresh();
}
} else {
if (virtualGrid) {
destroyVirtualGrid();
}
elements.cells.style.display = '';
renderAllCellsLegacy();
}
}
function renderColumnHeaders() {
elements.columnHeaders.innerHTML = "";
for (let col = 0; col < CONFIG.COLS; col++) {
const header = document.createElement("div");
header.className = "column-header";
header.textContent = getColName(col);
header.dataset.col = col;
header.addEventListener('click', handleColumnHeaderClick);
elements.columnHeaders.appendChild(header);
}
}
function renderRowHeaders() {
elements.rowHeaders.innerHTML = "";
for (let row = 0; row < CONFIG.ROWS; row++) {
const maxRows = useVirtualScroll ? Math.min(100, CONFIG.ROWS) : CONFIG.ROWS;
for (let row = 0; row < maxRows; row++) {
const header = document.createElement("div");
header.className = "row-header";
header.textContent = row + 1;
header.dataset.row = row;
header.addEventListener('click', handleRowHeaderClick);
elements.rowHeaders.appendChild(header);
}
}
function renderAllCellsLegacy() {
const ws = state.worksheets[state.activeWorksheet];
if (!ws) return;
elements.cells.innerHTML = "";
elements.cells.style.gridTemplateColumns = `repeat(${CONFIG.COLS}, ${CONFIG.COL_WIDTH}px)`;
elements.cells.style.gridTemplateRows = `repeat(${CONFIG.ROWS}, ${CONFIG.ROW_HEIGHT}px)`;
for (let row = 0; row < CONFIG.ROWS; row++) {
for (let col = 0; col < CONFIG.COLS; col++) {
const cell = document.createElement("div");
@ -123,22 +612,36 @@
}
}
renderAllCells();
}
function renderAllCells() {
const ws = state.worksheets[state.activeWorksheet];
if (!ws) return;
const cells = elements.cells.querySelectorAll(".cell");
cells.forEach((cell) => {
const row = parseInt(cell.dataset.row);
const col = parseInt(cell.dataset.col);
renderCell(row, col);
renderCellLegacy(row, col);
});
}
function renderAllCells() {
if (useVirtualScroll && virtualGrid) {
const ws = state.worksheets[state.activeWorksheet];
if (ws && ws.data) {
virtualGrid.loadData(ws.data);
}
} else {
renderAllCellsLegacy();
}
}
function renderCell(row, col) {
if (useVirtualScroll && virtualGrid) {
const ws = state.worksheets[state.activeWorksheet];
const data = ws?.data?.[`${row},${col}`];
virtualGrid.setCellValue(row, col, data);
} else {
renderCellLegacy(row, col);
}
}
function renderCellLegacy(row, col) {
const cell = elements.cells.querySelector(
`[data-row="${row}"][data-col="${col}"]`,
);
@ -299,6 +802,22 @@
document.getElementById("zoomInBtn")?.addEventListener("click", zoomIn);
document.getElementById("zoomOutBtn")?.addEventListener("click", zoomOut);
document
.getElementById("importXlsxBtn")
?.addEventListener("click", () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.xlsx,.xls,.csv,.ods';
input.onchange = async (e) => {
if (e.target.files[0]) {
await importXlsx(e.target.files[0]);
}
};
input.click();
});
document.getElementById("exportXlsxBtn")?.addEventListener("click", exportXlsx);
document.getElementById("exportCsvBtn")?.addEventListener("click", exportCsv);
document
.getElementById("findReplaceBtn")
?.addEventListener("click", showFindReplaceModal);
@ -520,12 +1039,17 @@
end: { row, col },
};
const cell = elements.cells.querySelector(
`[data-row="${row}"][data-col="${col}"]`,
);
if (cell) {
cell.classList.add("selected");
cell.scrollIntoView({ block: "nearest", inline: "nearest" });
if (useVirtualScroll && virtualGrid) {
virtualGrid.scrollToCell(row, col);
setTimeout(() => highlightVirtualCell(row, col), 50);
} else {
const cell = elements.cells.querySelector(
`[data-row="${row}"][data-col="${col}"]`,
);
if (cell) {
cell.classList.add("selected");
cell.scrollIntoView({ block: "nearest", inline: "nearest" });
}
}
updateCellAddress();
@ -533,6 +1057,14 @@
updateSelectionInfo();
}
function highlightVirtualCell(row, col) {
const cell = elements.cells.querySelector(`[data-row="${row}"][data-col="${col}"]`);
if (cell && !cell.classList.contains('selected')) {
clearSelection();
cell.classList.add('selected');
}
}
function extendSelection(row, col) {
clearSelection();
@ -695,8 +1227,14 @@
}
function setCellValue(row, col, value) {
if (!permissions.canEdit()) {
addChatMessage("system", "You don't have permission to edit this sheet");
return;
}
const ws = state.worksheets[state.activeWorksheet];
const key = `${row},${col}`;
const oldValue = ws.data[key]?.value || ws.data[key]?.formula || '';
saveToHistory();
@ -708,6 +1246,12 @@
ws.data[key] = { value };
}
if (useVirtualScroll && virtualGrid) {
virtualGrid.setCellValue(row, col, ws.data[key]);
}
auditLog.log('cell_change', { row, col, oldValue, newValue: value, cellRef: getCellRef(row, col) });
state.isDirty = true;
scheduleAutoSave();
broadcastChange("cell", { row, col, value });
@ -725,6 +1269,13 @@
return data.value || "";
}
function getCellValue(row, col) {
const data = getCellData(row, col);
if (!data) return "";
if (data.formula) return evaluateFormula(data.formula, row, col);
return data.value || "";
}
function evaluateFormula(formula, sourceRow, sourceCol) {
if (!formula.startsWith("=")) return formula;
@ -1539,6 +2090,127 @@
}
}
async function importXlsx(file) {
elements.saveStatus.textContent = "Importing...";
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/sheet/import', {
method: 'POST',
body: formData
});
if (response.ok) {
const data = await response.json();
state.sheetId = data.id;
state.sheetName = data.name || file.name.replace(/\.[^/.]+$/, '');
state.worksheets = data.worksheets || [{ name: "Sheet1", data: {} }];
if (elements.sheetName) elements.sheetName.value = state.sheetName;
CONFIG.ROWS = Math.max(CONFIG.ROWS, state.worksheets.reduce((max, ws) => {
const maxRow = Object.keys(ws.data || {}).reduce((m, key) => {
const [r] = key.split(',').map(Number);
return Math.max(m, r);
}, 0);
return Math.max(max, maxRow + 1);
}, CONFIG.ROWS));
window.history.replaceState({}, "", `#id=${state.sheetId}`);
renderWorksheetTabs();
renderGrid();
elements.saveStatus.textContent = "Imported";
addChatMessage("system", `Successfully imported ${file.name}`);
} else {
const err = await response.json();
elements.saveStatus.textContent = "Import failed";
addChatMessage("error", `Import failed: ${err.error || 'Unknown error'}`);
}
} catch (e) {
elements.saveStatus.textContent = "Import failed";
addChatMessage("error", `Import failed: ${e.message}`);
}
}
async function exportXlsx() {
elements.saveStatus.textContent = "Exporting...";
try {
if (!state.sheetId) {
const response = await saveSheet();
if (!response.ok) throw new Error('Failed to save first');
}
const response = await fetch('/api/sheet/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: state.sheetId,
format: 'xlsx'
})
});
if (response.ok) {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${state.sheetName || 'spreadsheet'}.xlsx`;
a.click();
URL.revokeObjectURL(url);
elements.saveStatus.textContent = "Exported";
addChatMessage("system", "Spreadsheet exported successfully");
} else {
const err = await response.json();
elements.saveStatus.textContent = "Export failed";
addChatMessage("error", `Export failed: ${err.error || 'Unknown error'}`);
}
} catch (e) {
elements.saveStatus.textContent = "Export failed";
addChatMessage("error", `Export failed: ${e.message}`);
}
}
async function exportCsv() {
elements.saveStatus.textContent = "Exporting...";
try {
if (!state.sheetId) {
await saveSheet();
}
const response = await fetch('/api/sheet/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: state.sheetId,
format: 'csv'
})
});
if (response.ok) {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${state.sheetName || 'spreadsheet'}.csv`;
a.click();
URL.revokeObjectURL(url);
elements.saveStatus.textContent = "Exported";
} else {
elements.saveStatus.textContent = "Export failed";
}
} catch (e) {
elements.saveStatus.textContent = "Export failed";
}
}
async function loadFromUrlParams() {
const hash = window.location.hash;
if (!hash) return;