- Renommage complet dans package.json, main.ts, UI, tray, notifications - GUID NSIS fixe pour mise à jour propre (pas de doublon d'installation) - Migration automatique des données depuis %APPDATA%\Minuteur Dragodinde\ - Rétrocompatibilité import : backups 'minuteur-dragodinde' toujours acceptés - Mise à jour README changelog, CLAUDE.md, docs, maquettes, page ntfy Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
385 lines
14 KiB
TypeScript
385 lines
14 KiB
TypeScript
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 = `
|
|
<div class="app-shell">
|
|
<div id="sb-container"></div>
|
|
<div class="main-area">
|
|
<header class="app-header">
|
|
<button class="app-hamburger" id="hamburger-btn">☰</button>
|
|
<div class="app-header-text">
|
|
<h1 class="app-title"><span class="app-title-icon">⚔</span> Obsidienne</h1>
|
|
<p class="app-subtitle">Dofus 3 · Gestion multi-enclos en temps réel</p>
|
|
</div>
|
|
<div class="app-hamburger" style="visibility:hidden;pointer-events:none;" aria-hidden="true"></div>
|
|
</header>
|
|
<div id="update-banner-root"></div>
|
|
<div class="tabs-row" id="tabs-row"></div>
|
|
<div id="enclos-content" class="main-content custom-scrollbar"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// 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<DashboardResult>({ 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 += `<div class="tab${dashActive}" data-view="dashboard"><span><span class="sb-item-icon material-symbols-outlined" style="font-size:15px;vertical-align:middle;margin-right:4px;">dashboard</span>Dashboard</span></div>`;
|
|
|
|
// Statistiques tab
|
|
const statsActive = activeView === 'statistiques' ? ' active' : '';
|
|
html += `<div class="tab${statsActive}" data-view="statistiques"><span><span class="sb-item-icon material-symbols-outlined" style="font-size:15px;vertical-align:middle;margin-right:4px;">bar_chart</span>Statistiques</span></div>`;
|
|
|
|
// Enclos tabs
|
|
enclosList.forEach((enc, idx) => {
|
|
const isActive = activeView === enc.id ? ' active' : '';
|
|
const isRunning = enc.running ? ' running' : '';
|
|
const canDelete = enclosList.length > 1;
|
|
html += `<div class="tab${isActive}${isRunning}" draggable="true" data-idx="${idx}" data-view="${enc.id}" id="tab-enc-${enc.id}">`;
|
|
html += `<span class="tab-dot"></span>`;
|
|
html += `<span>${esc(enc.name)}</span>`;
|
|
if (canDelete) {
|
|
html += `<button class="tab-del" data-delete-id="${enc.id}" title="Supprimer">✕</button>`;
|
|
}
|
|
html += `</div>`;
|
|
});
|
|
|
|
// Add enclos button
|
|
const disabled = enclosList.length >= MAX_ENCLOS ? ' disabled' : '';
|
|
html += `<button class="add-tab" id="add-enclos-btn"${disabled}>+ Enclos</button>`;
|
|
|
|
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<HTMLElement>;
|
|
|
|
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<Enclos>({ 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 = '';
|
|
}
|
|
}
|