diff --git a/server/src/wled_controller/api/__init__.py b/server/src/wled_controller/api/__init__.py index ce68de1..ae716cc 100644 --- a/server/src/wled_controller/api/__init__.py +++ b/server/src/wled_controller/api/__init__.py @@ -30,6 +30,7 @@ from .routes.mqtt import router as mqtt_router from .routes.game_integration import router as game_integration_router from .routes.audio_processing_templates import router as audio_processing_templates_router from .routes.audio_filters import router as audio_filters_router +from .routes.pattern_templates import router as pattern_templates_router router = APIRouter() router.include_router(system_router) @@ -60,5 +61,6 @@ router.include_router(mqtt_router) router.include_router(game_integration_router) router.include_router(audio_processing_templates_router) router.include_router(audio_filters_router) +router.include_router(pattern_templates_router) __all__ = ["router"] diff --git a/server/src/wled_controller/api/dependencies.py b/server/src/wled_controller/api/dependencies.py index 7a7a10e..a2ba87f 100644 --- a/server/src/wled_controller/api/dependencies.py +++ b/server/src/wled_controller/api/dependencies.py @@ -38,6 +38,7 @@ from wled_controller.core.game_integration.event_bus import GameEventBus from wled_controller.storage.mqtt_source_store import MQTTSourceStore from wled_controller.core.mqtt.mqtt_manager import MQTTManager from wled_controller.storage.audio_processing_template_store import AudioProcessingTemplateStore +from wled_controller.storage.pattern_template_store import PatternTemplateStore T = TypeVar("T") @@ -168,6 +169,10 @@ def get_audio_processing_template_store() -> AudioProcessingTemplateStore: return _get("audio_processing_template_store", "Audio processing template store") +def get_pattern_template_store() -> PatternTemplateStore: + return _get("pattern_template_store", "Pattern template store") + + def get_database() -> Database: return _get("database", "Database") @@ -233,6 +238,7 @@ def init_dependencies( mqtt_store: MQTTSourceStore | None = None, mqtt_manager: MQTTManager | None = None, audio_processing_template_store: AudioProcessingTemplateStore | None = None, + pattern_template_store: PatternTemplateStore | None = None, ): """Initialize global dependencies.""" _deps.update( @@ -267,5 +273,6 @@ def init_dependencies( "mqtt_store": mqtt_store, "mqtt_manager": mqtt_manager, "audio_processing_template_store": audio_processing_template_store, + "pattern_template_store": pattern_template_store, } ) diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index 6ac0196..2ebf594 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -52,6 +52,7 @@ from wled_controller.core.mqtt.mqtt_service import MQTTService from wled_controller.core.mqtt.mqtt_manager import MQTTManager from wled_controller.storage.mqtt_source_store import MQTTSourceStore from wled_controller.storage.audio_processing_template_store import AudioProcessingTemplateStore +from wled_controller.storage.pattern_template_store import PatternTemplateStore import wled_controller.core.audio.filters # noqa: F401 — trigger audio filter auto-registration from wled_controller.core.devices.mqtt_client import set_mqtt_service from wled_controller.core.backup.auto_backup import AutoBackupEngine @@ -108,6 +109,7 @@ ha_manager = HomeAssistantManager(ha_store) mqtt_source_store = MQTTSourceStore(db) audio_processing_template_store = AudioProcessingTemplateStore(db) game_integration_store = GameIntegrationStore(db) +pattern_template_store = PatternTemplateStore(db) game_event_bus = GameEventBus() register_community_adapters() @@ -236,6 +238,7 @@ async def lifespan(app: FastAPI): mqtt_store=mqtt_source_store, mqtt_manager=mqtt_manager, audio_processing_template_store=audio_processing_template_store, + pattern_template_store=pattern_template_store, ) # Register devices in processor manager for health monitoring diff --git a/server/src/wled_controller/static/css/graph-editor.css b/server/src/wled_controller/static/css/graph-editor.css index 47c98ad..b861a59 100644 --- a/server/src/wled_controller/static/css/graph-editor.css +++ b/server/src/wled_controller/static/css/graph-editor.css @@ -147,6 +147,90 @@ html:has(#tab-graph.active) { margin: 4px 2px; } +/* ── Toolbar overflow button (hidden on wide screens) ── */ + +.graph-tb-overflow-btn { + display: none; +} + +/* ── Toolbar overflow menu ── */ + +.graph-overflow-menu { + display: none; + position: absolute; + z-index: 25; + min-width: 170px; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 4px; + box-shadow: 0 4px 16px var(--shadow-color); + flex-direction: column; + gap: 2px; +} + +.graph-overflow-menu.open { + display: flex; +} + +.graph-overflow-menu.flip-up { + transform: translateY(-100%); +} + +.graph-overflow-menu button { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 10px; + border: none; + background: transparent; + color: var(--text-color); + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; + white-space: nowrap; + transition: background 0.15s; +} + +.graph-overflow-menu button:hover { + background: var(--bg-secondary); +} + +.graph-overflow-menu button.active { + background: var(--primary-color); + color: var(--primary-contrast); +} + +.graph-overflow-menu button:disabled { + opacity: 0.3; + cursor: default; + pointer-events: none; +} + +.graph-overflow-menu .icon { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.graph-overflow-sep { + height: 1px; + background: var(--border-color); + margin: 2px 4px; +} + +/* ── Responsive: collapse toolbar on narrow viewports ── */ + +@media (max-width: 700px) { + .graph-toolbar [data-collapse] { + display: none; + } + .graph-tb-overflow-btn { + display: flex; + } +} + /* ── Legend panel ── */ .graph-legend { diff --git a/server/src/wled_controller/static/js/app.ts b/server/src/wled_controller/static/js/app.ts index 9127c58..822d77b 100644 --- a/server/src/wled_controller/static/js/app.ts +++ b/server/src/wled_controller/static/js/app.ts @@ -193,7 +193,7 @@ import { loadGraphEditor, toggleGraphLegend, toggleGraphMinimap, toggleGraphFilter, toggleGraphFilterTypes, toggleGraphHelp, graphUndo, graphRedo, graphFitAll, graphZoomIn, graphZoomOut, graphRelayout, - graphToggleFullscreen, graphAddEntity, + graphToggleFullscreen, graphAddEntity, toggleToolbarOverflow, closeToolbarOverflow, } from './features/graph-editor.ts'; // Layer 6: tabs, navigation, command palette, settings @@ -575,6 +575,8 @@ Object.assign(window, { graphRelayout, graphToggleFullscreen, graphAddEntity, + toggleToolbarOverflow, + closeToolbarOverflow, // tabs / navigation / command palette switchTab, diff --git a/server/src/wled_controller/static/js/features/graph-editor.ts b/server/src/wled_controller/static/js/features/graph-editor.ts index 6caa04f..da55a75 100644 --- a/server/src/wled_controller/static/js/features/graph-editor.ts +++ b/server/src/wled_controller/static/js/features/graph-editor.ts @@ -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 { - - + + - + - + - + - - + + - + - - + + - + - + - + + + + + + + + + + ${t('graph.search')} + + + + ${t('graph.filter')} + + + + ${t('graph.legend')} + + + + ${t('graph.minimap')} + + + + + ${t('graph.help.undo') || 'Undo'} + + + + ${t('graph.help.redo') || 'Redo'} + + + + + ${t('graph.relayout')} + + + + ${t('graph.fullscreen')} + + + + + ${t('graph.help_title') || 'Shortcuts'} + diff --git a/server/src/wled_controller/storage/database.py b/server/src/wled_controller/storage/database.py index 342a1d3..e90760d 100644 --- a/server/src/wled_controller/storage/database.py +++ b/server/src/wled_controller/storage/database.py @@ -59,6 +59,7 @@ _ENTITY_TABLES = [ "mqtt_sources", "game_integrations", "audio_processing_templates", + "pattern_templates", ]