diff --git a/demo/editor/README.md b/demo/editor/README.md index 77240b5..dfe31fe 100644 --- a/demo/editor/README.md +++ b/demo/editor/README.md @@ -4,7 +4,14 @@ This is the **editor demo** for monitorat. Any widget that can be edited or conf ### How to Edit -Click the pencil icon next to or on any wiki or card, or create a new entry with "Add Widget item" button, to open the editor. +Use the widget affordances: + +- Wiki: hover the wiki header and click the pencil-paper icon +- Reminders: click the header `...` to add a reminder, or hover a card and + click its corner `...` to edit it +- Services: click the header `...` to add a service, or hover a card and click + its corner `...` to open the info modal, then use the wrench to edit +- Metrics: click the `...` in the snapshot header to configure snapshot tiles ### Features @@ -21,15 +28,14 @@ Click the pencil icon next to or on any wiki or card, or create a new entry with ### Card Editors -- **Add**: Click the "Add Widget Item" button to add a new display card for +- **Add**: Click the header `...` action to add a new display card for - System Metrics - Services - Reminders -- **Edit**: Click the pencil icon to edit a display card +- **Edit**: Use the card `...` action or modal wrench for widget items - **Save**: Click Save to persist changes ### Caveats - **Histories**: Charts and Tables, in addition to their display quantities and dropdowns, are only editable through YAML - **Notifications**: Apprise URLs for the notification harness are also YAML-only (used by multiple widgets) - diff --git a/monitorat/static/controls/listing.js b/monitorat/static/controls/listing.js index de627f5..3b5a355 100644 --- a/monitorat/static/controls/listing.js +++ b/monitorat/static/controls/listing.js @@ -64,7 +64,7 @@ class ListingControls { initializeAddButton() { if (!this.addConfig) return; - const addButton = this.container.querySelector(this.selectors.add); + let addButton = this.container.querySelector(this.selectors.add); if (!addButton) return; if (this.addConfig.enabled === false) { @@ -72,6 +72,25 @@ class ListingControls { return; } + const affordance = this.addConfig.affordance; + if (affordance) { + const controls = window.monitorShared?.EditorControls; + const createButton = + affordance.type === 'edit' + ? controls?.createEditButton + : controls?.createOverflowButton; + const nextButton = createButton?.({ + className: affordance.className || '', + title: affordance.title || '', + label: affordance.label || '', + visible: affordance.visible !== false, + }); + if (nextButton) { + addButton.replaceWith(nextButton); + addButton = nextButton; + } + } + if (typeof this.addConfig.onClick === 'function') { addButton.addEventListener('click', () => this.addConfig.onClick()); } diff --git a/monitorat/static/editor/editor.css b/monitorat/static/editor/editor.css index f789516..f4ec01e 100644 --- a/monitorat/static/editor/editor.css +++ b/monitorat/static/editor/editor.css @@ -159,33 +159,119 @@ font-weight: 500; } -.editor-edit-btn { +.editor-affordance-btn { display: inline-flex; align-items: center; justify-content: center; + width: 28px; + height: 28px; + padding: 0; + margin: 0; border: none; - padding: 4px; - border-radius: 999px; + border-radius: 8px; background: transparent; color: var(--text-muted); cursor: pointer; + opacity: 0.42; transition: - background 0.15s ease, + background-color 0.15s ease, + opacity 0.15s ease, color 0.15s ease; - width: 28px; - height: 28px; - margin-left: 8px; } -.editor-edit-btn svg { +.editor-affordance-btn svg { width: 16px; height: 16px; display: block; + transition: transform 0.15s ease; +} + +.editor-affordance-btn:hover, +.editor-affordance-btn:focus-visible { + background: color-mix(in srgb, var(--text-primary) 12%, transparent); + opacity: 1; + color: var(--text-primary); +} + +.editor-affordance-btn:hover svg, +.editor-affordance-btn:focus-visible svg { + transform: scale(1.14); +} + +.editor-affordance-btn:focus-visible { + outline: 2px solid var(--border-muted); + outline-offset: 2px; +} + +.editor-affordance-visible { + opacity: 0.34; +} + +.editor-affordance-reveal-parent { + position: relative; +} + +.editor-affordance-corner { + position: absolute; + top: 6px; + right: 6px; } -.editor-edit-btn:hover { +.editor-affordance-card-action { + top: 0; + right: 0; + width: 34px; + height: 34px; + border-radius: 0 8px 0 10px; + color: var(--text-muted); + transition: background-color 0.15s ease; +} + +.editor-affordance-card-action .editor-affordance-btn { + width: 100%; + height: 100%; + border-radius: inherit; + opacity: 1; + color: inherit; +} + +.editor-affordance-card-action .editor-affordance-btn:hover, +.editor-affordance-card-action .editor-affordance-btn:focus-visible { background: transparent; +} + +.editor-affordance-card-action:hover, +.editor-affordance-card-action:focus-within { color: var(--text-primary); + background: color-mix(in srgb, var(--text-primary) 12%, transparent); +} + +.editor-affordance-reveal { + opacity: 0; + pointer-events: none; + transform: scale(0.94); +} + +.editor-affordance-reveal-parent:hover .editor-affordance-reveal, +.editor-affordance-reveal-parent:focus-within .editor-affordance-reveal, +.editor-affordance-reveal:hover, +.editor-affordance-reveal:focus-visible { + opacity: 0.58; + pointer-events: auto; + transform: scale(1); +} + +.editor-affordance-reveal:hover, +.editor-affordance-reveal:focus-visible { + opacity: 1; +} + +@media (hover: none), (pointer: coarse) { + .editor-affordance-reveal { + opacity: 0.42; + pointer-events: auto; + transform: scale(1); + } } @media (max-height: 600px) { diff --git a/monitorat/static/editor/editor.js b/monitorat/static/editor/editor.js index 9980175..fc65527 100644 --- a/monitorat/static/editor/editor.js +++ b/monitorat/static/editor/editor.js @@ -7,8 +7,6 @@ window.Editor = (() => { const CHEVRON_UP = ''; const ICON_SAVE = ''; - const ICON_RESTORE = - ''; const ICON_DELETE = ''; const ICON_CANCEL = @@ -348,3 +346,87 @@ window.Editor = (() => { clearDraft, }; })(); + +window.monitorShared = window.monitorShared || {}; +window.monitorShared.EditorControls = (() => { + function createActionButton(options = {}) { + const { + className = '', + icon = '', + iconName = '', + title = '', + label = '', + visible = false, + state = visible ? 'visible' : 'reveal', + } = options; + + const button = document.createElement('button'); + button.type = 'button'; + button.className = [ + 'editor-affordance-btn', + state === 'visible' + ? 'editor-affordance-visible' + : state === 'reveal' + ? 'editor-affordance-reveal' + : '', + className, + ] + .filter(Boolean) + .join(' '); + if (title) { + button.title = title; + } + if (label) { + button.setAttribute('aria-label', label); + } + button.innerHTML = + icon || window.monitorShared.IconHandler.getActionIcon(iconName); + return button; + } + + function createOverflowButton(options = {}) { + return createActionButton({ + ...options, + icon: + options.icon || + window.monitorShared.IconHandler.getActionIcon('overflow'), + }); + } + + function createEditButton(options = {}) { + return createActionButton({ + ...options, + iconName: options.iconName || 'edit', + }); + } + + function wrapCardAction(button, className = '') { + const container = document.createElement('div'); + container.className = [ + 'editor-affordance-card-action', + 'editor-affordance-corner', + 'editor-affordance-reveal', + className, + ] + .filter(Boolean) + .join(' '); + container.appendChild(button); + return { button, container }; + } + + function createCardOverflowButton(options = {}) { + const { className = '', ...buttonOptions } = options; + const button = createOverflowButton({ + ...buttonOptions, + state: 'none', + }); + return wrapCardAction(button, className); + } + + return { + createActionButton, + createCardOverflowButton, + createEditButton, + createOverflowButton, + }; +})(); diff --git a/monitorat/static/themes/default.css b/monitorat/static/themes/default.css index 982852b..2a07993 100644 --- a/monitorat/static/themes/default.css +++ b/monitorat/static/themes/default.css @@ -945,12 +945,18 @@ h6:hover .header-anchor { /* Utilities */ .hover-expand { + color: var(--text-muted); transform: scale(1); transform-origin: center; - transition: transform 0.15s ease; + transition: + color 0.15s ease, + transform 0.15s ease; } .hover-expand:hover, -.hover-expand-parent:hover .hover-expand { +.hover-expand:focus-visible, +.hover-expand-parent:hover .hover-expand, +.hover-expand-parent:focus-within .hover-expand { + color: var(--text-primary); transform: scale(1.12); } diff --git a/monitorat/static/ui/icons.js b/monitorat/static/ui/icons.js index 315f376..97f1182 100644 --- a/monitorat/static/ui/icons.js +++ b/monitorat/static/ui/icons.js @@ -6,6 +6,16 @@ */ const IconHandler = (() => { + const ACTION_ICONS = { + edit: '', + 'edit-doc': + '', + overflow: + '', + wrench: + '', + }; + function getFileExtension(path) { return path?.split('.')?.pop()?.toLowerCase() || ''; } @@ -93,13 +103,18 @@ const IconHandler = (() => { const parser = new DOMParser(); const parsed = parser.parseFromString(svgText, 'image/svg+xml'); const root = parsed.documentElement; - if (root && root.tagName.toLowerCase().endsWith('svg')) { + if (root?.tagName.toLowerCase().endsWith('svg')) { return root; } return null; } + function getActionIcon(name) { + return ACTION_ICONS[name] || ''; + } + return { + getActionIcon, renderIcon, }; })(); diff --git a/monitorat/widgets/metrics/app.js b/monitorat/widgets/metrics/app.js index 76f7b06..5e68a67 100644 --- a/monitorat/widgets/metrics/app.js +++ b/monitorat/widgets/metrics/app.js @@ -294,19 +294,22 @@ class MetricsWidget { } addEditButton() { - const controlsRow = this.container.querySelector( - '[data-metrics="widget-controls"]', + const snapshotHeader = this.container.querySelector( + '[data-metrics-section-header="snapshot"]', ); - if (!controlsRow) return; - - controlsRow.style.display = ''; - const configureBtn = controlsRow.querySelector('.metrics-configure'); - if (!configureBtn) return; - - configureBtn.style.display = ''; + if (!snapshotHeader) return; + + const controls = window.monitorShared?.EditorControls; + const configureBtn = + controls?.createOverflowButton({ + visible: true, + title: 'Configure metrics', + label: 'Configure metrics', + }) || document.createElement('button'); configureBtn.addEventListener('click', () => { this.openMetricsEditor(); }); + snapshotHeader.appendChild(configureBtn); } setupEventListeners() { diff --git a/monitorat/widgets/metrics/features/editor.js b/monitorat/widgets/metrics/features/editor.js index 60ea242..a2f075b 100644 --- a/monitorat/widgets/metrics/features/editor.js +++ b/monitorat/widgets/metrics/features/editor.js @@ -35,7 +35,7 @@ class MetricsEditor { file: sourcePath, content: '', useForm: true, - title: 'Snapshot Tiles', + title: 'Metrics Snapshot Tiles', labels: { edit: 'Configure', preview: 'Preview' }, onSave: async () => { const payload = this.collectQuantities(formContainer); diff --git a/monitorat/widgets/metrics/index.html b/monitorat/widgets/metrics/index.html index 0575c26..a853dfe 100644 --- a/monitorat/widgets/metrics/index.html +++ b/monitorat/widgets/metrics/index.html @@ -1,11 +1,6 @@
- -
diff --git a/monitorat/widgets/metrics/style.css b/monitorat/widgets/metrics/style.css index 8329349..bc5bb72 100644 --- a/monitorat/widgets/metrics/style.css +++ b/monitorat/widgets/metrics/style.css @@ -12,6 +12,19 @@ gap: 6px; } +.metrics .alerts-header { + display: flex; + align-items: center; + gap: 12px; +} + +.metrics .alerts-actions { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + .metrics .metrics-events .feature-header[data-metrics-section-header="events"] { margin-bottom: 4px; } @@ -28,17 +41,16 @@ margin-bottom: 0; } -.metrics .alerts-header { +.metrics .feature-header[data-metrics-section-header="snapshot"] { display: flex; align-items: center; - gap: 12px; + gap: 8px; } -.metrics .alerts-actions { - display: flex; - align-items: center; - gap: 12px; - flex-wrap: wrap; +.metrics + .feature-header[data-metrics-section-header="snapshot"] + > .editor-affordance-btn { + margin-inline-start: auto; } .metrics .alert { diff --git a/monitorat/widgets/reminders/features/alerts.js b/monitorat/widgets/reminders/features/alerts.js index d0aee4b..d3e23f9 100644 --- a/monitorat/widgets/reminders/features/alerts.js +++ b/monitorat/widgets/reminders/features/alerts.js @@ -50,11 +50,14 @@ class RemindersAlerts { const hasBadge = showBadge && reminder._source; const isDisabled = reminder.disabled === true; const statusClass = isDisabled ? 'disabled' : reminder.status; + const canEdit = + !disableActions && this.widget.canEditReminders() && !reminder._source; const classes = [ 'reminder-alert', 'status-card', `status-${statusClass}`, 'hover-expand-parent', + canEdit ? 'editor-affordance-reveal-parent' : '', isDisabled ? 'is-disabled' : '', ]; @@ -122,28 +125,19 @@ class RemindersAlerts { content.appendChild(textDiv); content.appendChild(statsDiv); - const canEdit = - !disableActions && this.widget.canEditReminders() && !reminder._source; - const actions = []; if (canEdit) { - const editButton = document.createElement('button'); - editButton.type = 'button'; - editButton.className = - 'reminder-edit-button editor-edit-btn hover-expand'; - editButton.title = 'Edit reminder'; - editButton.setAttribute('aria-label', 'Edit reminder'); - editButton.innerHTML = ` - - - - - `; + const controls = window.monitorShared?.EditorControls; + const editAction = controls?.createCardOverflowButton({ + title: 'Edit reminder', + label: 'Edit reminder', + }); + const editButton = editAction?.button || document.createElement('button'); editButton.addEventListener('click', (event) => { event.stopPropagation(); this.widget.openReminderEditor(reminder); }); - actions.push(editButton); + actions.push(editAction?.container || editButton); } const Alerts = window.monitorShared.Alerts; diff --git a/monitorat/widgets/reminders/features/controls.js b/monitorat/widgets/reminders/features/controls.js index 90cae56..cdb822e 100644 --- a/monitorat/widgets/reminders/features/controls.js +++ b/monitorat/widgets/reminders/features/controls.js @@ -40,6 +40,12 @@ class RemindersControls { }, add: { enabled: this.widget.canEditReminders(), + affordance: { + type: 'overflow', + visible: true, + title: 'Add reminder', + label: 'Add reminder', + }, onClick: () => { this.widget.openReminderEditor(); }, diff --git a/monitorat/widgets/reminders/style.css b/monitorat/widgets/reminders/style.css index 4a545f8..664dfe2 100644 --- a/monitorat/widgets/reminders/style.css +++ b/monitorat/widgets/reminders/style.css @@ -61,14 +61,7 @@ position: relative; } .reminder-alert-content { - padding-right: 28px; -} -.reminder-edit-button { - position: absolute; - top: 0; - bottom: 0; - right: 10px; - margin: auto; + padding-right: 30px; } .reminder-editor-preview { padding: 12px 0; diff --git a/monitorat/widgets/services/features/controls.js b/monitorat/widgets/services/features/controls.js index 96ebc13..64f2ce5 100644 --- a/monitorat/widgets/services/features/controls.js +++ b/monitorat/widgets/services/features/controls.js @@ -36,6 +36,12 @@ class ServicesControls { }, add: { enabled: this.widget.canEditServices(), + affordance: { + type: 'overflow', + visible: true, + title: 'Add service', + label: 'Add service', + }, onClick: () => { this.widget.openServiceEditor(null); }, diff --git a/monitorat/widgets/services/features/info.js b/monitorat/widgets/services/features/info.js index 7fd9790..5b516e1 100644 --- a/monitorat/widgets/services/features/info.js +++ b/monitorat/widgets/services/features/info.js @@ -8,7 +8,6 @@ const SERVICE_TYPE_ICONS = { }; const SERVICE_ACTION_ICONS = { - info: '', external: '', local: @@ -21,10 +20,6 @@ class ServicesInfo { this.widget = widget; } - getInfoIcon() { - return SERVICE_ACTION_ICONS.info; - } - getServiceStatusInfo(service) { const statusData = service._source ? this.widget.statusBySource[service._source] || {} @@ -98,6 +93,7 @@ class ServicesInfo { service.local && service.local !== service.url && service.local.trim() !== ''; + const canEdit = this.widget.canEditServices() && !service._source; const imgBase = service._source ? `api/proxy/${service._source}/img` : this.widget.getImgBase(); @@ -125,11 +121,22 @@ class ServicesInfo { const content = `
-
- ${service.name} - +
+
+ ${service.name} + +
+ ${service.name}
- ${service.name} + ${ + canEdit + ? ` + + ` + : '' + }
${statusHtml}