import type { CommandBus } from '@application/handlers/CommandBus'; import type { QueryBus } from '@application/handlers/QueryBus'; import type { UIState } from '@presentation/state/UIState'; import type { DashboardResult } from '@application/queries/GetDashboard'; import { Sidebar } from './Sidebar'; import { Dashboard } from './Dashboard'; import { EnclosView } from './EnclosView'; import { AccouplementView } from './AccouplementView'; import { ReapproView } from './ReapproView'; import { InventaireView } from './InventaireView'; import { ParametresView } from './ParametresView'; import { WorkflowsView } from './WorkflowsView'; import { StatistiquesView } from './StatistiquesView'; import { UpdateBanner } from './UpdateBanner'; import { Toast } from './Toast'; import { ConfirmModal } from './ConfirmModal'; import { UndoManager } from '@presentation/services/UndoManager'; import { esc } from '@presentation/helpers/format'; import { enclosGlobalState } from '@presentation/helpers/gauge-live'; import { MAX_ENCLOS } from '@domain/entities/Enclos'; import type { Enclos } from '@domain/entities/Enclos'; type ChildComponent = { update(): void; destroy(): void }; export class App { private root: HTMLElement; private sidebar: Sidebar; private updateBanner: UpdateBanner; private activeChild: ChildComponent | null = null; private unsubscribe: (() => void) | null = null; private rafId: number | null = null; private completionIntervalId: number | null = null; private ctrlZHandler: ((e: KeyboardEvent) => void) | null = null; private lastView: string | number | null = null; // Tab drag-and-drop state private dragSrcIdx: number | null = null; constructor( private commandBus: CommandBus, private queryBus: QueryBus, private uiState: UIState, rootElement: HTMLElement, private playSound?: (name: string) => void, ) { this.root = rootElement; this.sidebar = new Sidebar(uiState, queryBus); this.updateBanner = new UpdateBanner(); } render(): void { this.root.innerHTML = `

⚔ Obsidienne

Dofus 3 · Gestion multi-enclos en temps réel

`; // Mount sidebar const sbContainer = this.root.querySelector('#sb-container') as HTMLElement; this.sidebar.render(sbContainer); // Mount update banner const bannerRoot = this.root.querySelector('#update-banner-root') as HTMLElement; this.updateBanner.render(bannerRoot); // Mount toast container const appShell = this.root.querySelector('.app-shell') as HTMLElement; Toast.mount(appShell); // Ctrl+Z → undo this.ctrlZHandler = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'z' && UndoManager.canUndo()) { e.preventDefault(); UndoManager.undo(); } }; document.addEventListener('keydown', this.ctrlZHandler); // DEV badge via IPC (pas d'executeJavaScript) const api = (window as any).electronAPI; api?.onDevMode?.(() => { const p = this.root.querySelector('header p'); if (p && !document.getElementById('dev-badge')) { const b = document.createElement('span'); b.id = 'dev-badge'; b.textContent = 'DEV'; b.style.cssText = 'background:#ff9820;color:#000;padding:2px 10px;border-radius:8px;font-size:0.72rem;font-weight:800;margin-left:8px;vertical-align:middle'; p.appendChild(b); } }); // Hamburger toggle const hamburgerBtn = this.root.querySelector('#hamburger-btn') as HTMLElement; hamburgerBtn.addEventListener('click', () => this.uiState.toggleSidebar()); // Subscribe to UI state changes this.unsubscribe = this.uiState.subscribe(() => this.onStateChange()); // Initial renders this.renderTabs(); this.renderContent(); this.updateSidebarState(); // Start animation loop this.startAnimationLoop(); // Interval indépendant du focus fenêtre pour la détection de fin de session this.completionIntervalId = window.setInterval(() => { this.checkAllEnclosCompletion(); }, 1000); } private onStateChange(): void { this.renderTabs(); this.renderContent(); this.sidebar.update(); this.updateSidebarState(); } private updateSidebarState(): void { const sidebarEl = this.root.querySelector('.sidebar-new') as HTMLElement | null; if (sidebarEl) { sidebarEl.classList.toggle('sidebar-closed', !this.uiState.sidebarOpen); } } private getDashboardData(): DashboardResult { return this.queryBus.execute({ type: 'get-dashboard' }); } // ── Tabs ────────────────────────────────────────────────────── private renderTabs(): void { const tabsRow = this.root.querySelector('#tabs-row') as HTMLElement | null; if (!tabsRow) return; const data = this.getDashboardData(); const enclosList = data.enclosSummaries; const activeView = this.uiState.activeView; let html = ''; // Dashboard tab const dashActive = activeView === 'dashboard' ? ' active' : ''; html += `
dashboardDashboard
`; // Statistiques tab const statsActive = activeView === 'statistiques' ? ' active' : ''; html += `
bar_chartStatistiques
`; // Enclos tabs enclosList.forEach((enc, idx) => { const isActive = activeView === enc.id ? ' active' : ''; const isRunning = enc.running ? ' running' : ''; const canDelete = enclosList.length > 1; html += `
`; html += ``; html += `${esc(enc.name)}`; if (canDelete) { html += ``; } html += `
`; }); // Add enclos button const disabled = enclosList.length >= MAX_ENCLOS ? ' disabled' : ''; html += ``; tabsRow.innerHTML = html; // Tab click events tabsRow.querySelectorAll('.tab[data-view]').forEach(tab => { tab.addEventListener('click', (e) => { const target = e.target as HTMLElement; if (target.classList.contains('tab-del')) return; const view = (tab as HTMLElement).dataset['view']!; const viewValue = /^\d+$/.test(view) ? Number(view) : view; this.uiState.setActiveView(viewValue); }); }); // Delete events tabsRow.querySelectorAll('.tab-del').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const id = Number((btn as HTMLElement).dataset['deleteId']); const ok = await ConfirmModal.show('Supprimer l\'enclos', 'Cette action est irréversible. Continuer ?'); if (!ok) return; const hasSnap = await UndoManager.snapshotCurrent('Suppression enclos'); this.commandBus.execute({ type: 'delete-enclos', enclosId: id }); Toast.show('success', 'Enclos supprimé.', hasSnap ? { label: 'Annuler', callback: () => UndoManager.undo() } : undefined); if (this.uiState.activeView === id) { const newData = this.getDashboardData(); const firstEnclos = newData.enclosSummaries[0]; this.uiState.setActiveView(firstEnclos ? firstEnclos.id : 'dashboard'); } else { this.uiState.notify(); } }); }); // Add enclos button const addBtn = tabsRow.querySelector('#add-enclos-btn') as HTMLElement | null; if (addBtn) { addBtn.addEventListener('click', () => { this.commandBus.execute({ type: 'create-enclos' }); const newData = this.getDashboardData(); const last = newData.enclosSummaries[newData.enclosSummaries.length - 1]; if (last) this.uiState.setActiveView(last.id); }); } // Drag and drop this.setupTabDragAndDrop(tabsRow); } private setupTabDragAndDrop(tabsRow: HTMLElement): void { const tabs = tabsRow.querySelectorAll('.tab[draggable=true]') as NodeListOf; tabs.forEach(tab => { tab.addEventListener('dragstart', (e) => { this.dragSrcIdx = Number(tab.dataset['idx']); (e as DragEvent).dataTransfer!.effectAllowed = 'move'; tab.classList.add('dragging'); }); tab.addEventListener('dragover', (e) => { e.preventDefault(); (e as DragEvent).dataTransfer!.dropEffect = 'move'; tab.classList.add('drag-over'); }); tab.addEventListener('dragleave', () => { tab.classList.remove('drag-over'); }); tab.addEventListener('dragend', () => { tab.classList.remove('dragging'); tabs.forEach(t => t.classList.remove('drag-over')); }); tab.addEventListener('drop', (e) => { e.preventDefault(); tab.classList.remove('drag-over'); const destIdx = Number(tab.dataset['idx']); if (this.dragSrcIdx !== null && this.dragSrcIdx !== destIdx) { this.commandBus.execute({ type: 'reorder-enclos', fromIndex: this.dragSrcIdx, toIndex: destIdx, }); this.uiState.notify(); } this.dragSrcIdx = null; }); }); } // ── Content routing ─────────────────────────────────────────── private renderContent(): void { const view = this.uiState.activeView; if (view === this.lastView && this.activeChild) { this.activeChild.update(); return; } if (this.activeChild) { this.activeChild.destroy(); this.activeChild = null; } const container = this.root.querySelector('#enclos-content') as HTMLElement | null; if (!container) return; container.innerHTML = ''; this.lastView = view; if (view === 'dashboard') { const child = new Dashboard(this.commandBus, this.queryBus, this.uiState); child.render(container); this.activeChild = child; } else if (view === 'accouplement') { const child = new AccouplementView(this.commandBus, this.queryBus); child.render(container); this.activeChild = child; } else if (view === 'appro') { const child = new ReapproView(this.commandBus, this.queryBus); child.render(container); this.activeChild = child; } else if (view === 'inventaire') { const child = new InventaireView(this.commandBus, this.queryBus); child.render(container); this.activeChild = child; } else if (view === 'workflows') { const child = new WorkflowsView(this.commandBus, this.queryBus, this.uiState); child.render(container); this.activeChild = child; } else if (view === 'statistiques') { const child = new StatistiquesView(this.commandBus, this.queryBus); child.render(container); this.activeChild = child; } else if (view === 'parametres') { const child = new ParametresView(this.commandBus, this.queryBus, this.playSound); child.render(container); this.activeChild = child; } else if (typeof view === 'number') { const child = new EnclosView(this.commandBus, this.queryBus, this.uiState); child.render(container, view); this.activeChild = child; } } // ── Live update loop ────────────────────────────────────────── private startAnimationLoop(): void { const loop = () => { this.updateTabDots(); if (this.activeChild) this.activeChild.update(); this.rafId = requestAnimationFrame(loop); }; this.rafId = requestAnimationFrame(loop); } private updateTabDots(): void { const data = this.getDashboardData(); data.enclosSummaries.forEach(enc => { const tab = this.root.querySelector(`#tab-enc-${enc.id}`) as HTMLElement | null; if (!tab) return; tab.classList.toggle('running', enc.running); }); } /** Appelle complete-timer sur tout enclos dont toutes les cibles sont atteintes. */ private checkAllEnclosCompletion(): void { const data = this.getDashboardData(); data.enclosSummaries.forEach(summary => { if (!summary.running) return; const enc = this.queryBus.execute({ type: 'get-enclos-detail', enclosId: summary.id }); if (!enc.dragodindes.length || !enc.activeGauges.length) return; const { allDone } = enclosGlobalState(enc); if (allDone) { this.commandBus.execute({ type: 'complete-timer', enclosId: summary.id }); } }); } destroy(): void { if (this.rafId !== null) { cancelAnimationFrame(this.rafId); this.rafId = null; } if (this.completionIntervalId !== null) { clearInterval(this.completionIntervalId); this.completionIntervalId = null; } if (this.ctrlZHandler) { document.removeEventListener('keydown', this.ctrlZHandler); this.ctrlZHandler = null; } if (this.unsubscribe) { this.unsubscribe(); this.unsubscribe = null; } if (this.activeChild) { this.activeChild.destroy(); this.activeChild = null; } this.sidebar.destroy(); this.updateBanner.destroy(); this.root.innerHTML = ''; } }