fix: register pattern-templates API route; add responsive toolbar overflow menu
Lint & Test / test (push) Successful in 2m19s

Pattern templates route existed but was never wired into the router,
dependencies, or database table allowlist — causing 404 on graph tab load.

Graph toolbar now collapses secondary actions into a "more" overflow menu
on viewports narrower than 700px. Primary controls (fit, zoom, add) stay
visible; search, filter, panels, undo/redo, relayout, fullscreen, and
help move into the dropdown.
This commit is contained in:
2026-04-12 21:22:50 +03:00
parent e678e5590a
commit 38f73badbf
7 changed files with 238 additions and 14 deletions
@@ -535,6 +535,86 @@ export function graphFitAll(): void {
export function graphZoomIn(): void { if (_canvas) _canvas.zoomIn(); }
export function graphZoomOut(): void { if (_canvas) _canvas.zoomOut(); }
/* ── Toolbar overflow menu ──────────────────────────────── */
export function toggleToolbarOverflow(): void {
const menu = document.getElementById('graph-overflow-menu');
if (!menu) return;
const open = menu.classList.toggle('open');
if (open) {
_syncOverflowState();
// Position menu near the overflow button
const btn = document.querySelector('.graph-tb-overflow-btn') as HTMLElement | null;
const container = menu.parentElement;
if (btn && container) {
const btnRect = btn.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const menuWidth = 180;
// Default: below the button, right-aligned
let top = btnRect.bottom - containerRect.top + 4;
let left = btnRect.right - containerRect.left - menuWidth;
// Flip above if would overflow bottom
if (top + 240 > containerRect.height) {
top = btnRect.top - containerRect.top - 4;
menu.classList.add('flip-up');
} else {
menu.classList.remove('flip-up');
}
// Clamp left edge
if (left < 4) left = 4;
menu.style.top = `${top}px`;
menu.style.left = `${left}px`;
}
requestAnimationFrame(() => {
document.addEventListener('pointerdown', _closeOverflowOutside, { once: true });
});
}
}
export function closeToolbarOverflow(): void {
const menu = document.getElementById('graph-overflow-menu');
if (menu) menu.classList.remove('open');
}
function _closeOverflowOutside(e: Event): void {
const menu = document.getElementById('graph-overflow-menu');
if (!menu || !menu.classList.contains('open')) return;
const target = e.target as Element;
if (menu.contains(target) || target.closest('.graph-tb-overflow-btn')) {
// Re-listen if click was inside
document.addEventListener('pointerdown', _closeOverflowOutside, { once: true });
return;
}
closeToolbarOverflow();
}
function _syncOverflowState(): void {
// Sync disabled state for undo/redo
const undoBtn = document.getElementById('graph-undo-btn') as HTMLButtonElement | null;
const redoBtn = document.getElementById('graph-redo-btn') as HTMLButtonElement | null;
const overflowUndo = document.getElementById('graph-overflow-undo') as HTMLButtonElement | null;
const overflowRedo = document.getElementById('graph-overflow-redo') as HTMLButtonElement | null;
if (undoBtn && overflowUndo) overflowUndo.disabled = undoBtn.disabled;
if (redoBtn && overflowRedo) overflowRedo.disabled = redoBtn.disabled;
// Sync active state for toggle buttons
const pairs: [string, string][] = [
['graph-legend-toggle', 'graph-overflow-legend'],
['graph-minimap-toggle', 'graph-overflow-minimap'],
['graph-help-toggle', 'graph-overflow-help'],
];
for (const [tbId, ovId] of pairs) {
const tb = document.getElementById(tbId);
const ov = document.getElementById(ovId);
if (tb && ov) ov.classList.toggle('active', tb.classList.contains('active'));
}
// Sync filter active state
const filterTb = document.querySelector('.graph-filter-btn');
const filterOv = document.querySelector('.graph-overflow-filter-btn');
if (filterTb && filterOv) {
filterOv.classList.toggle('active', filterTb.classList.contains('active'));
}
}
export function graphToggleFullscreen(): void {
const container = document.querySelector('#graph-editor-content .graph-container');
if (!container) return;
@@ -929,40 +1009,85 @@ function _graphHTML(): string {
<button class="btn-icon" onclick="graphZoomOut()" title="${t('graph.zoom_out')}">
<svg class="icon" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/><path d="M8 11h6"/></svg>
</button>
<span class="graph-toolbar-sep"></span>
<button class="btn-icon" onclick="openCommandPalette()" title="${t('graph.search')} (/)">
<span class="graph-toolbar-sep" data-collapse></span>
<button class="btn-icon" onclick="openCommandPalette()" title="${t('graph.search')} (/)" data-collapse>
<svg class="icon" viewBox="0 0 24 24"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
</button>
<button class="btn-icon graph-filter-btn" onclick="toggleGraphFilter()" title="${t('graph.filter')} (F)">
<button class="btn-icon graph-filter-btn" onclick="toggleGraphFilter()" title="${t('graph.filter')} (F)" data-collapse>
<svg class="icon" viewBox="0 0 24 24"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
</button>
<button class="btn-icon${_legendVisible ? ' active' : ''}" id="graph-legend-toggle" onclick="toggleGraphLegend()" title="${t('graph.legend')}">
<button class="btn-icon${_legendVisible ? ' active' : ''}" id="graph-legend-toggle" onclick="toggleGraphLegend()" title="${t('graph.legend')}" data-collapse>
<svg class="icon" viewBox="0 0 24 24"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/></svg>
</button>
<button class="btn-icon${_minimapVisible ? ' active' : ''}" id="graph-minimap-toggle" onclick="toggleGraphMinimap()" title="${t('graph.minimap')}">
<button class="btn-icon${_minimapVisible ? ' active' : ''}" id="graph-minimap-toggle" onclick="toggleGraphMinimap()" title="${t('graph.minimap')}" data-collapse>
<svg class="icon" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2"/><rect width="7" height="5" x="14" y="14" rx="1" fill="currentColor" opacity="0.3"/></svg>
</button>
<span class="graph-toolbar-sep"></span>
<button class="btn-icon" id="graph-undo-btn" onclick="graphUndo()" title="${t('graph.help.undo') || 'Undo'} (Ctrl+Z)" disabled>
<span class="graph-toolbar-sep" data-collapse></span>
<button class="btn-icon" id="graph-undo-btn" onclick="graphUndo()" title="${t('graph.help.undo') || 'Undo'} (Ctrl+Z)" disabled data-collapse>
<svg class="icon" viewBox="0 0 24 24"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/></svg>
</button>
<button class="btn-icon" id="graph-redo-btn" onclick="graphRedo()" title="${t('graph.help.redo') || 'Redo'} (Ctrl+Shift+Z)" disabled>
<button class="btn-icon" id="graph-redo-btn" onclick="graphRedo()" title="${t('graph.help.redo') || 'Redo'} (Ctrl+Shift+Z)" disabled data-collapse>
<svg class="icon" viewBox="0 0 24 24"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7"/></svg>
</button>
<span class="graph-toolbar-sep"></span>
<button class="btn-icon" onclick="graphRelayout()" title="${t('graph.relayout')}">
<span class="graph-toolbar-sep" data-collapse></span>
<button class="btn-icon" onclick="graphRelayout()" title="${t('graph.relayout')}" data-collapse>
<svg class="icon" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
</button>
<button class="btn-icon" onclick="graphToggleFullscreen()" title="${t('graph.fullscreen')} (F11)">
<button class="btn-icon" onclick="graphToggleFullscreen()" title="${t('graph.fullscreen')} (F11)" data-collapse>
<svg class="icon" viewBox="0 0 24 24"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></svg>
</button>
<span class="graph-toolbar-sep"></span>
<span class="graph-toolbar-sep" data-collapse></span>
<button class="btn-icon" onclick="graphAddEntity()" title="${t('graph.add_entity')} (+)">
<svg class="icon" viewBox="0 0 24 24"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
</button>
<button class="btn-icon${_helpVisible ? ' active' : ''}" id="graph-help-toggle" onclick="toggleGraphHelp()" title="${t('graph.help_title') || 'Shortcuts'} (?)">
<button class="btn-icon${_helpVisible ? ' active' : ''}" id="graph-help-toggle" onclick="toggleGraphHelp()" title="${t('graph.help_title') || 'Shortcuts'} (?)" data-collapse>
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
</button>
<button class="btn-icon graph-tb-overflow-btn" onclick="toggleToolbarOverflow()" title="${t('graph.more') || 'More'}">
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>
</button>
</div>
<div class="graph-overflow-menu" id="graph-overflow-menu">
<button onclick="openCommandPalette(); closeToolbarOverflow()">
<svg class="icon" viewBox="0 0 24 24"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
<span>${t('graph.search')}</span>
</button>
<button class="graph-overflow-filter-btn" onclick="toggleGraphFilter(); closeToolbarOverflow()">
<svg class="icon" viewBox="0 0 24 24"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
<span>${t('graph.filter')}</span>
</button>
<button id="graph-overflow-legend" onclick="toggleGraphLegend(); closeToolbarOverflow()">
<svg class="icon" viewBox="0 0 24 24"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/></svg>
<span>${t('graph.legend')}</span>
</button>
<button id="graph-overflow-minimap" onclick="toggleGraphMinimap(); closeToolbarOverflow()">
<svg class="icon" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2"/><rect width="7" height="5" x="14" y="14" rx="1" fill="currentColor" opacity="0.3"/></svg>
<span>${t('graph.minimap')}</span>
</button>
<div class="graph-overflow-sep"></div>
<button id="graph-overflow-undo" onclick="graphUndo(); closeToolbarOverflow()">
<svg class="icon" viewBox="0 0 24 24"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/></svg>
<span>${t('graph.help.undo') || 'Undo'}</span>
</button>
<button id="graph-overflow-redo" onclick="graphRedo(); closeToolbarOverflow()">
<svg class="icon" viewBox="0 0 24 24"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7"/></svg>
<span>${t('graph.help.redo') || 'Redo'}</span>
</button>
<div class="graph-overflow-sep"></div>
<button onclick="graphRelayout(); closeToolbarOverflow()">
<svg class="icon" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
<span>${t('graph.relayout')}</span>
</button>
<button onclick="graphToggleFullscreen(); closeToolbarOverflow()">
<svg class="icon" viewBox="0 0 24 24"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></svg>
<span>${t('graph.fullscreen')}</span>
</button>
<div class="graph-overflow-sep"></div>
<button id="graph-overflow-help" onclick="toggleGraphHelp(); closeToolbarOverflow()">
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
<span>${t('graph.help_title') || 'Shortcuts'}</span>
</button>
</div>
<div class="graph-legend${_legendVisible ? ' visible' : ''}">